chore: format with updated rustfmt and taplo rules
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ie9ef5fc421fa20071946cf1073f7920c6a6a6964
This commit is contained in:
parent
605b1a5181
commit
c306383d27
72 changed files with 11217 additions and 10487 deletions
27
.rustfmt.toml
Normal file
27
.rustfmt.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
condense_wildcard_suffixes = true
|
||||
doc_comment_code_block_width = 80
|
||||
edition = "2024" # Keep in sync with Cargo.toml.
|
||||
enum_discrim_align_threshold = 60
|
||||
force_explicit_abi = false
|
||||
force_multiline_blocks = true
|
||||
format_code_in_doc_comments = true
|
||||
format_macro_matchers = true
|
||||
format_strings = true
|
||||
group_imports = "StdExternalCrate"
|
||||
hex_literal_case = "Upper"
|
||||
imports_granularity = "Crate"
|
||||
imports_layout = "HorizontalVertical"
|
||||
inline_attribute_width = 60
|
||||
match_block_trailing_comma = true
|
||||
max_width = 80
|
||||
newline_style = "Unix"
|
||||
normalize_comments = true
|
||||
normalize_doc_attributes = true
|
||||
overflow_delimited_expr = true
|
||||
struct_field_align_threshold = 60
|
||||
tab_spaces = 2
|
||||
unstable_features = true
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
wrap_comments = true
|
||||
|
||||
15
.taplo.toml
Normal file
15
.taplo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[formatting]
|
||||
align_entries = true
|
||||
column_width = 110
|
||||
compact_arrays = false
|
||||
reorder_inline_tables = false
|
||||
reorder_keys = true
|
||||
|
||||
[[rule]]
|
||||
include = [ "**/Cargo.toml" ]
|
||||
keys = [ "package" ]
|
||||
|
||||
[rule.formatting]
|
||||
reorder_keys = false
|
||||
|
||||
|
||||
75
Cargo.toml
75
Cargo.toml
|
|
@ -9,49 +9,54 @@ members = [
|
|||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
authors = [ "NotAShelf <raf@notashelf.dev" ]
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
repository = "https://gitub.com/feel-co/fc"
|
||||
authors = ["NotAShelf <raf@notashelf.dev"]
|
||||
rust-version = "1.91.1"
|
||||
version = "0.1.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Components
|
||||
fc-common = {path = "./crates/common"}
|
||||
fc-evaluator = {path = "./crates/evaluator"}
|
||||
fc-queue-runner = {path = "./crates/queue-runner"}
|
||||
fc-server = {path = "./crates/server"}
|
||||
fc-common = { path = "./crates/common" }
|
||||
fc-evaluator = { path = "./crates/evaluator" }
|
||||
fc-queue-runner = { path = "./crates/queue-runner" }
|
||||
fc-server = { path = "./crates/server" }
|
||||
|
||||
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
axum = "0.8.8"
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
|
||||
anyhow = "1.0.100"
|
||||
thiserror = "2.0.17"
|
||||
git2 = "0.20.2"
|
||||
clap = { version = "4.5.51", features = ["derive"] }
|
||||
config = "0.15.18"
|
||||
tempfile = "3.8"
|
||||
toml = "0.9.8"
|
||||
tower-http = { version = "0.6.8", features = ["cors", "trace", "limit", "fs", "set-header"] }
|
||||
tower = "0.5.3"
|
||||
futures = "0.3.31"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
axum-extra = { version = "0.10", features = ["typed-header"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
nix-nar = "0.3"
|
||||
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
|
||||
async-stream = "0.3"
|
||||
dashmap = "6"
|
||||
regex = "1"
|
||||
askama = "0.12"
|
||||
askama_axum = "0.4"
|
||||
async-stream = "0.3"
|
||||
axum = "0.8.8"
|
||||
axum-extra = { version = "0.10", features = [ "typed-header" ] }
|
||||
chrono = { version = "0.4.42", features = [ "serde" ] }
|
||||
clap = { version = "4.5.51", features = [ "derive" ] }
|
||||
config = "0.15.18"
|
||||
dashmap = "6"
|
||||
futures = "0.3.31"
|
||||
git2 = "0.20.2"
|
||||
hex = "0.4"
|
||||
hmac = "0.12"
|
||||
lettre = { version = "0.11", default-features = false, features = [
|
||||
"tokio1-rustls-tls",
|
||||
"smtp-transport",
|
||||
"builder",
|
||||
] }
|
||||
nix-nar = "0.3"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = [ "json", "rustls-tls" ] }
|
||||
serde = { version = "1.0.228", features = [ "derive" ] }
|
||||
serde_json = "1.0.145"
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.8.6", features = [ "runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate" ] }
|
||||
tempfile = "3.8"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = [ "full" ] }
|
||||
tokio-util = { version = "0.7", features = [ "io" ] }
|
||||
toml = "0.9.8"
|
||||
tower = "0.5.3"
|
||||
tower-http = { version = "0.6.8", features = [ "cors", "trace", "limit", "fs", "set-header" ] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = [ "env-filter", "json" ] }
|
||||
uuid = { version = "1.18.1", features = [ "v4", "serde" ] }
|
||||
|
|
|
|||
|
|
@ -7,23 +7,23 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
git2.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
sha2.workspace = true
|
||||
git2.workspace = true
|
||||
hex.workspace = true
|
||||
lettre.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@
|
|||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::DeclarativeConfig;
|
||||
use crate::error::Result;
|
||||
use crate::models::{CreateJobset, CreateProject};
|
||||
use crate::repo;
|
||||
use crate::{
|
||||
config::DeclarativeConfig,
|
||||
error::Result,
|
||||
models::{CreateJobset, CreateProject},
|
||||
repo,
|
||||
};
|
||||
|
||||
/// Bootstrap declarative configuration into the database.
|
||||
///
|
||||
|
|
@ -34,14 +36,11 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
|||
|
||||
// Upsert projects and their jobsets
|
||||
for decl_project in &config.projects {
|
||||
let project = repo::projects::upsert(
|
||||
pool,
|
||||
CreateProject {
|
||||
let project = repo::projects::upsert(pool, CreateProject {
|
||||
name: decl_project.name.clone(),
|
||||
repository_url: decl_project.repository_url.clone(),
|
||||
description: decl_project.description.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
|
|
@ -51,9 +50,7 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
|||
);
|
||||
|
||||
for decl_jobset in &decl_project.jobsets {
|
||||
let jobset = repo::jobsets::upsert(
|
||||
pool,
|
||||
CreateJobset {
|
||||
let jobset = repo::jobsets::upsert(pool, CreateJobset {
|
||||
project_id: project.id,
|
||||
name: decl_jobset.name.clone(),
|
||||
nix_expression: decl_jobset.nix_expression.clone(),
|
||||
|
|
@ -62,8 +59,7 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
|||
check_interval: Some(decl_jobset.check_interval),
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
|
|
@ -81,7 +77,8 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
|||
let key_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let api_key =
|
||||
repo::api_keys::upsert(pool, &decl_key.name, &key_hash, &decl_key.role).await?;
|
||||
repo::api_keys::upsert(pool, &decl_key.name, &key_hash, &decl_key.role)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
name = %api_key.name,
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ pub struct CacheUploadConfig {
|
|||
pub store_uri: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
/// Declarative project/jobset/api-key definitions.
|
||||
/// These are upserted on server startup, enabling fully declarative operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
|
|
@ -201,7 +200,8 @@ impl Default for TracingConfig {
|
|||
impl Default for DatabaseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: "postgresql://fc_ci:password@localhost/fc_ci".to_string(),
|
||||
url: "postgresql://fc_ci:password@localhost/fc_ci"
|
||||
.to_string(),
|
||||
max_connections: 20,
|
||||
min_connections: 5,
|
||||
connect_timeout: 30,
|
||||
|
|
@ -217,7 +217,9 @@ impl DatabaseConfig {
|
|||
return Err(anyhow::anyhow!("Database URL cannot be empty"));
|
||||
}
|
||||
|
||||
if !self.url.starts_with("postgresql://") && !self.url.starts_with("postgres://") {
|
||||
if !self.url.starts_with("postgresql://")
|
||||
&& !self.url.starts_with("postgres://")
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Database URL must start with postgresql:// or postgres://"
|
||||
));
|
||||
|
|
@ -283,7 +285,9 @@ impl Default for QueueRunnerConfig {
|
|||
impl Default for GcConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gc_roots_dir: PathBuf::from("/nix/var/nix/gcroots/per-user/fc/fc-roots"),
|
||||
gc_roots_dir: PathBuf::from(
|
||||
"/nix/var/nix/gcroots/per-user/fc/fc-roots",
|
||||
),
|
||||
enabled: true,
|
||||
max_age_days: 30,
|
||||
cleanup_interval: 3600,
|
||||
|
|
@ -300,7 +304,6 @@ impl Default for LogConfig {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
impl Default for CacheConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -310,21 +313,23 @@ impl Default for CacheConfig {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let mut settings = config_crate::Config::builder();
|
||||
|
||||
// Load default configuration
|
||||
settings = settings.add_source(config_crate::Config::try_from(&Self::default())?);
|
||||
settings =
|
||||
settings.add_source(config_crate::Config::try_from(&Self::default())?);
|
||||
|
||||
// Load from config file if it exists
|
||||
if let Ok(config_path) = std::env::var("FC_CONFIG_FILE") {
|
||||
if std::path::Path::new(&config_path).exists() {
|
||||
settings = settings.add_source(config_crate::File::with_name(&config_path));
|
||||
settings =
|
||||
settings.add_source(config_crate::File::with_name(&config_path));
|
||||
}
|
||||
} else if std::path::Path::new("fc.toml").exists() {
|
||||
settings = settings.add_source(config_crate::File::with_name("fc").required(false));
|
||||
settings = settings
|
||||
.add_source(config_crate::File::with_name("fc").required(false));
|
||||
}
|
||||
|
||||
// Load from environment variables with FC_ prefix (highest priority)
|
||||
|
|
@ -406,9 +411,10 @@ impl Config {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = Config::default();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
//! Database connection and pool management
|
||||
|
||||
use crate::config::DatabaseConfig;
|
||||
use sqlx::{PgPool, Row, postgres::PgPoolOptions};
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::{PgPool, Row, postgres::PgPoolOptions};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::DatabaseConfig;
|
||||
|
||||
pub struct Database {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
//! GC root management - prevents nix-store --gc from deleting build outputs
|
||||
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
os::unix::fs::symlink,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Remove GC root symlinks with mtime older than max_age. Returns count removed.
|
||||
pub fn cleanup_old_roots(roots_dir: &Path, max_age: Duration) -> std::io::Result<u64> {
|
||||
/// Remove GC root symlinks with mtime older than max_age. Returns count
|
||||
/// removed.
|
||||
pub fn cleanup_old_roots(
|
||||
roots_dir: &Path,
|
||||
max_age: Duration,
|
||||
) -> std::io::Result<u64> {
|
||||
if !roots_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
|
@ -28,7 +34,8 @@ pub fn cleanup_old_roots(roots_dir: &Path, max_age: Duration) -> std::io::Result
|
|||
};
|
||||
|
||||
if let Ok(age) = now.duration_since(modified)
|
||||
&& age > max_age {
|
||||
&& age > max_age
|
||||
{
|
||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
||||
warn!(
|
||||
"Failed to remove old GC root {}: {e}",
|
||||
|
|
@ -55,7 +62,10 @@ impl GcRoots {
|
|||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&roots_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
std::fs::set_permissions(
|
||||
&roots_dir,
|
||||
std::fs::Permissions::from_mode(0o700),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(Self { roots_dir, enabled })
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ pub async fn run_migrations(database_url: &str) -> anyhow::Result<()> {
|
|||
Ok(()) => {
|
||||
info!("Database migrations completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to run database migrations: {}", e);
|
||||
Err(anyhow::anyhow!("Migration failed: {e}"))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,37 +43,33 @@ pub async fn run() -> anyhow::Result<()> {
|
|||
info!("Running database migrations");
|
||||
crate::run_migrations(&database_url).await?;
|
||||
info!("Migrations completed successfully");
|
||||
}
|
||||
},
|
||||
Commands::Validate { database_url } => {
|
||||
info!("Validating database schema");
|
||||
let pool = sqlx::PgPool::connect(&database_url).await?;
|
||||
crate::validate_schema(&pool).await?;
|
||||
info!("Schema validation passed");
|
||||
}
|
||||
},
|
||||
Commands::Create { name } => {
|
||||
create_migration(&name)?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_migration(name: &str) -> anyhow::Result<()> {
|
||||
use chrono::Utc;
|
||||
use std::fs;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let filename = format!("{timestamp}_{name}.sql");
|
||||
let filepath = format!("crates/common/migrations/{filename}");
|
||||
|
||||
let content = format!(
|
||||
"-- Migration: {}\n\
|
||||
-- Created: {}\n\
|
||||
\n\
|
||||
-- Add your migration SQL here\n\
|
||||
\n\
|
||||
-- Uncomment below for rollback SQL\n\
|
||||
-- ROLLBACK;\n",
|
||||
"-- Migration: {}\n-- Created: {}\n\n-- Add your migration SQL here\n\n-- \
|
||||
Uncomment below for rollback SQL\n-- ROLLBACK;\n",
|
||||
name,
|
||||
Utc::now().to_rfc3339()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::CiError;
|
||||
use crate::error::Result;
|
||||
use crate::{CiError, error::Result};
|
||||
|
||||
/// Result of probing a flake repository.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -85,7 +84,10 @@ fn to_flake_ref(url: &str) -> String {
|
|||
}
|
||||
|
||||
/// Probe a flake repository to discover its outputs and suggest jobsets.
|
||||
pub async fn probe_flake(repo_url: &str, revision: Option<&str>) -> Result<FlakeProbeResult> {
|
||||
pub async fn probe_flake(
|
||||
repo_url: &str,
|
||||
revision: Option<&str>,
|
||||
) -> Result<FlakeProbeResult> {
|
||||
let base_ref = to_flake_ref(repo_url);
|
||||
let flake_ref = if let Some(rev) = revision {
|
||||
format!("{base_ref}?rev={rev}")
|
||||
|
|
@ -93,7 +95,8 @@ pub async fn probe_flake(repo_url: &str, revision: Option<&str>) -> Result<Flake
|
|||
base_ref
|
||||
};
|
||||
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
||||
let output =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
||||
tokio::process::Command::new("nix")
|
||||
.args([
|
||||
"--extra-experimental-features",
|
||||
|
|
@ -108,19 +111,27 @@ pub async fn probe_flake(repo_url: &str, revision: Option<&str>) -> Result<Flake
|
|||
.await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CiError::Timeout("Flake probe timed out after 60s".to_string()))?
|
||||
.map_err(|e| CiError::NixEval(format!("Failed to run nix flake show: {e}")))?;
|
||||
.map_err(|_| {
|
||||
CiError::Timeout("Flake probe timed out after 60s".to_string())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
CiError::NixEval(format!("Failed to run nix flake show: {e}"))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Check for common non-flake case
|
||||
if stderr.contains("does not provide attribute") || stderr.contains("has no 'flake.nix'") {
|
||||
if stderr.contains("does not provide attribute")
|
||||
|| stderr.contains("has no 'flake.nix'")
|
||||
{
|
||||
return Ok(FlakeProbeResult {
|
||||
is_flake: false,
|
||||
outputs: Vec::new(),
|
||||
suggested_jobsets: Vec::new(),
|
||||
metadata: FlakeMetadata::default(),
|
||||
error: Some("Repository does not contain a flake.nix".to_string()),
|
||||
error: Some(
|
||||
"Repository does not contain a flake.nix".to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
if stderr.contains("denied")
|
||||
|
|
@ -143,8 +154,11 @@ pub async fn probe_flake(repo_url: &str, revision: Option<&str>) -> Result<Flake
|
|||
);
|
||||
}
|
||||
|
||||
let raw: serde_json::Value = serde_json::from_str(&stdout[..stdout.len().min(MAX_OUTPUT_SIZE)])
|
||||
.map_err(|e| CiError::NixEval(format!("Failed to parse flake show output: {e}")))?;
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_str(&stdout[..stdout.len().min(MAX_OUTPUT_SIZE)])
|
||||
.map_err(|e| {
|
||||
CiError::NixEval(format!("Failed to parse flake show output: {e}"))
|
||||
})?;
|
||||
|
||||
let top = match raw.as_object() {
|
||||
Some(obj) => obj,
|
||||
|
|
@ -152,7 +166,7 @@ pub async fn probe_flake(repo_url: &str, revision: Option<&str>) -> Result<Flake
|
|||
return Err(CiError::NixEval(
|
||||
"Unexpected flake show output format".to_string(),
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
|
|
@ -229,13 +243,16 @@ pub async fn probe_flake(repo_url: &str, revision: Option<&str>) -> Result<Flake
|
|||
})
|
||||
}
|
||||
|
||||
/// Extract system names from a flake output value (e.g., `packages.x86_64-linux`).
|
||||
/// Extract system names from a flake output value (e.g.,
|
||||
/// `packages.x86_64-linux`).
|
||||
pub(crate) fn extract_systems(val: &serde_json::Value) -> Vec<String> {
|
||||
let mut systems = Vec::new();
|
||||
if let Some(obj) = val.as_object() {
|
||||
for key in obj.keys() {
|
||||
// System names follow the pattern `arch-os` (e.g., x86_64-linux, aarch64-darwin)
|
||||
if key.contains('-') && (key.contains("linux") || key.contains("darwin")) {
|
||||
// System names follow the pattern `arch-os` (e.g., x86_64-linux,
|
||||
// aarch64-darwin)
|
||||
if key.contains('-') && (key.contains("linux") || key.contains("darwin"))
|
||||
{
|
||||
systems.push(key.clone());
|
||||
}
|
||||
}
|
||||
|
|
@ -246,9 +263,10 @@ pub(crate) fn extract_systems(val: &serde_json::Value) -> Vec<String> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_systems_typical_flake() {
|
||||
let val = json!({
|
||||
|
|
@ -257,10 +275,11 @@ mod tests {
|
|||
"x86_64-darwin": { "hello": {} }
|
||||
});
|
||||
let systems = extract_systems(&val);
|
||||
assert_eq!(
|
||||
systems,
|
||||
vec!["aarch64-linux", "x86_64-darwin", "x86_64-linux"]
|
||||
);
|
||||
assert_eq!(systems, vec![
|
||||
"aarch64-linux",
|
||||
"x86_64-darwin",
|
||||
"x86_64-linux"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -328,7 +347,9 @@ mod tests {
|
|||
outputs: Vec::new(),
|
||||
suggested_jobsets: Vec::new(),
|
||||
metadata: FlakeMetadata::default(),
|
||||
error: Some("Repository does not contain a flake.nix".to_string()),
|
||||
error: Some(
|
||||
"Repository does not contain a flake.nix".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
//! Notification dispatch for build events
|
||||
|
||||
use crate::config::{EmailConfig, NotificationsConfig};
|
||||
use crate::models::{Build, BuildStatus, Project};
|
||||
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
config::{EmailConfig, NotificationsConfig},
|
||||
models::{Build, BuildStatus, Project},
|
||||
};
|
||||
|
||||
/// Dispatch all configured notifications for a completed build.
|
||||
pub async fn dispatch_build_finished(
|
||||
build: &Build,
|
||||
|
|
@ -19,18 +21,21 @@ pub async fn dispatch_build_finished(
|
|||
|
||||
// 2. GitHub commit status
|
||||
if let Some(ref token) = config.github_token
|
||||
&& project.repository_url.contains("github.com") {
|
||||
&& project.repository_url.contains("github.com")
|
||||
{
|
||||
set_github_status(token, &project.repository_url, commit_hash, build).await;
|
||||
}
|
||||
|
||||
// 3. Gitea/Forgejo commit status
|
||||
if let (Some(url), Some(token)) = (&config.gitea_url, &config.gitea_token) {
|
||||
set_gitea_status(url, token, &project.repository_url, commit_hash, build).await;
|
||||
set_gitea_status(url, token, &project.repository_url, commit_hash, build)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 4. Email notification
|
||||
if let Some(ref email_config) = config.email
|
||||
&& (!email_config.on_failure_only || build.status == BuildStatus::Failed) {
|
||||
&& (!email_config.on_failure_only || build.status == BuildStatus::Failed)
|
||||
{
|
||||
send_email_notification(email_config, build, project).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -67,19 +72,24 @@ async fn run_command_notification(cmd: &str, build: &Build, project: &Project) {
|
|||
} else {
|
||||
info!(build_id = %build.id, "RunCommand completed successfully");
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => error!(build_id = %build.id, "RunCommand execution failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_github_status(token: &str, repo_url: &str, commit: &str, build: &Build) {
|
||||
async fn set_github_status(
|
||||
token: &str,
|
||||
repo_url: &str,
|
||||
commit: &str,
|
||||
build: &Build,
|
||||
) {
|
||||
// Parse owner/repo from URL
|
||||
let (owner, repo) = match parse_github_repo(repo_url) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!("Cannot parse GitHub owner/repo from {repo_url}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let (state, description) = match build.status {
|
||||
|
|
@ -90,7 +100,8 @@ async fn set_github_status(token: &str, repo_url: &str, commit: &str, build: &Bu
|
|||
BuildStatus::Cancelled => ("error", "Build cancelled"),
|
||||
};
|
||||
|
||||
let url = format!("https://api.github.com/repos/{owner}/{repo}/statuses/{commit}");
|
||||
let url =
|
||||
format!("https://api.github.com/repos/{owner}/{repo}/statuses/{commit}");
|
||||
let body = serde_json::json!({
|
||||
"state": state,
|
||||
"description": description,
|
||||
|
|
@ -115,7 +126,7 @@ async fn set_github_status(token: &str, repo_url: &str, commit: &str, build: &Bu
|
|||
} else {
|
||||
info!(build_id = %build.id, "Set GitHub commit status: {state}");
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => error!("GitHub status API request failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -133,7 +144,7 @@ async fn set_gitea_status(
|
|||
None => {
|
||||
warn!("Cannot parse Gitea owner/repo from {repo_url}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let (state, description) = match build.status {
|
||||
|
|
@ -167,7 +178,7 @@ async fn set_gitea_status(
|
|||
} else {
|
||||
info!(build_id = %build.id, "Set Gitea commit status: {state}");
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => error!("Gitea status API request failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -190,7 +201,10 @@ fn parse_github_repo(url: &str) -> Option<(String, String)> {
|
|||
None
|
||||
}
|
||||
|
||||
fn parse_gitea_repo(repo_url: &str, base_url: &str) -> Option<(String, String)> {
|
||||
fn parse_gitea_repo(
|
||||
repo_url: &str,
|
||||
base_url: &str,
|
||||
) -> Option<(String, String)> {
|
||||
let url = repo_url.trim_end_matches(".git");
|
||||
let base = base_url.trim_end_matches('/');
|
||||
if let Some(rest) = url.strip_prefix(&format!("{base}/")) {
|
||||
|
|
@ -202,10 +216,19 @@ fn parse_gitea_repo(repo_url: &str, base_url: &str) -> Option<(String, String)>
|
|||
None
|
||||
}
|
||||
|
||||
async fn send_email_notification(config: &EmailConfig, build: &Build, project: &Project) {
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
async fn send_email_notification(
|
||||
config: &EmailConfig,
|
||||
build: &Build,
|
||||
project: &Project,
|
||||
) {
|
||||
use lettre::{
|
||||
AsyncSmtpTransport,
|
||||
AsyncTransport,
|
||||
Message,
|
||||
Tokio1Executor,
|
||||
message::header::ContentType,
|
||||
transport::smtp::authentication::Credentials,
|
||||
};
|
||||
|
||||
let status_str = match build.status {
|
||||
BuildStatus::Completed => "SUCCESS",
|
||||
|
|
@ -220,13 +243,8 @@ async fn send_email_notification(config: &EmailConfig, build: &Build, project: &
|
|||
);
|
||||
|
||||
let body = format!(
|
||||
"Build notification from FC CI\n\n\
|
||||
Project: {}\n\
|
||||
Job: {}\n\
|
||||
Status: {}\n\
|
||||
Derivation: {}\n\
|
||||
Output: {}\n\
|
||||
Build ID: {}\n",
|
||||
"Build notification from FC CI\n\nProject: {}\nJob: {}\nStatus: \
|
||||
{}\nDerivation: {}\nOutput: {}\nBuild ID: {}\n",
|
||||
project.name,
|
||||
build.job_name,
|
||||
status_str,
|
||||
|
|
@ -242,14 +260,14 @@ async fn send_email_notification(config: &EmailConfig, build: &Build, project: &
|
|||
Err(e) => {
|
||||
error!("Invalid from address '{}': {e}", config.from_address);
|
||||
return;
|
||||
}
|
||||
},
|
||||
})
|
||||
.to(match to_addr.parse() {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
warn!("Invalid to address '{to_addr}': {e}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
})
|
||||
.subject(&subject)
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
|
|
@ -259,7 +277,7 @@ async fn send_email_notification(config: &EmailConfig, build: &Build, project: &
|
|||
Err(e) => {
|
||||
error!("Failed to build email: {e}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut mailer_builder = if config.tls {
|
||||
|
|
@ -268,16 +286,17 @@ async fn send_email_notification(config: &EmailConfig, build: &Build, project: &
|
|||
Err(e) => {
|
||||
error!("Failed to create SMTP transport: {e}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp_host)
|
||||
.port(config.smtp_port)
|
||||
};
|
||||
|
||||
if let (Some(user), Some(pass)) = (&config.smtp_user, &config.smtp_password) {
|
||||
mailer_builder =
|
||||
mailer_builder.credentials(Credentials::new(user.clone(), pass.clone()));
|
||||
if let (Some(user), Some(pass)) = (&config.smtp_user, &config.smtp_password)
|
||||
{
|
||||
mailer_builder = mailer_builder
|
||||
.credentials(Credentials::new(user.clone(), pass.clone()));
|
||||
}
|
||||
|
||||
let mailer = mailer_builder.build();
|
||||
|
|
@ -285,10 +304,10 @@ async fn send_email_notification(config: &EmailConfig, build: &Build, project: &
|
|||
match mailer.send(email).await {
|
||||
Ok(_) => {
|
||||
info!(build_id = %build.id, to = to_addr, "Email notification sent");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(build_id = %build.id, to = to_addr, "Failed to send email: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,46 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::ApiKey;
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::ApiKey,
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, name: &str, key_hash: &str, role: &str) -> Result<ApiKey> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
key_hash: &str,
|
||||
role: &str,
|
||||
) -> Result<ApiKey> {
|
||||
sqlx::query_as::<_, ApiKey>(
|
||||
"INSERT INTO api_keys (name, key_hash, role) VALUES ($1, $2, $3) RETURNING *",
|
||||
"INSERT INTO api_keys (name, key_hash, role) VALUES ($1, $2, $3) \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(key_hash)
|
||||
.bind(role)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict("API key with this hash already exists".to_string())
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &PgPool, name: &str, key_hash: &str, role: &str) -> Result<ApiKey> {
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
key_hash: &str,
|
||||
role: &str,
|
||||
) -> Result<ApiKey> {
|
||||
sqlx::query_as::<_, ApiKey>(
|
||||
"INSERT INTO api_keys (name, key_hash, role) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (key_hash) DO UPDATE SET \
|
||||
name = EXCLUDED.name, \
|
||||
role = EXCLUDED.role \
|
||||
RETURNING *",
|
||||
"INSERT INTO api_keys (name, key_hash, role) VALUES ($1, $2, $3) ON \
|
||||
CONFLICT (key_hash) DO UPDATE SET name = EXCLUDED.name, role = \
|
||||
EXCLUDED.role RETURNING *",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(key_hash)
|
||||
|
|
@ -37,7 +50,10 @@ pub async fn upsert(pool: &PgPool, name: &str, key_hash: &str, role: &str) -> Re
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn get_by_hash(pool: &PgPool, key_hash: &str) -> Result<Option<ApiKey>> {
|
||||
pub async fn get_by_hash(
|
||||
pool: &PgPool,
|
||||
key_hash: &str,
|
||||
) -> Result<Option<ApiKey>> {
|
||||
sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys WHERE key_hash = $1")
|
||||
.bind(key_hash)
|
||||
.fetch_optional(pool)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::BuildDependency;
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::BuildDependency,
|
||||
};
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
|
|
@ -10,32 +12,40 @@ pub async fn create(
|
|||
dependency_build_id: Uuid,
|
||||
) -> Result<BuildDependency> {
|
||||
sqlx::query_as::<_, BuildDependency>(
|
||||
"INSERT INTO build_dependencies (build_id, dependency_build_id) VALUES ($1, $2) RETURNING *",
|
||||
"INSERT INTO build_dependencies (build_id, dependency_build_id) VALUES \
|
||||
($1, $2) RETURNING *",
|
||||
)
|
||||
.bind(build_id)
|
||||
.bind(dependency_build_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Dependency from {build_id} to {dependency_build_id} already exists"
|
||||
))
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_for_build(pool: &PgPool, build_id: Uuid) -> Result<Vec<BuildDependency>> {
|
||||
sqlx::query_as::<_, BuildDependency>("SELECT * FROM build_dependencies WHERE build_id = $1")
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
) -> Result<Vec<BuildDependency>> {
|
||||
sqlx::query_as::<_, BuildDependency>(
|
||||
"SELECT * FROM build_dependencies WHERE build_id = $1",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub async fn check_deps_for_builds(
|
||||
pool: &PgPool,
|
||||
build_ids: &[Uuid],
|
||||
|
|
@ -46,29 +56,32 @@ pub async fn check_deps_for_builds(
|
|||
|
||||
// Find build_ids that have incomplete deps
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT DISTINCT bd.build_id FROM build_dependencies bd \
|
||||
JOIN builds b ON bd.dependency_build_id = b.id \
|
||||
WHERE bd.build_id = ANY($1) AND b.status != 'completed'",
|
||||
"SELECT DISTINCT bd.build_id FROM build_dependencies bd JOIN builds b ON \
|
||||
bd.dependency_build_id = b.id WHERE bd.build_id = ANY($1) AND b.status \
|
||||
!= 'completed'",
|
||||
)
|
||||
.bind(build_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
let incomplete: std::collections::HashSet<Uuid> = rows.into_iter().map(|(id,)| id).collect();
|
||||
let incomplete: std::collections::HashSet<Uuid> =
|
||||
rows.into_iter().map(|(id,)| id).collect();
|
||||
|
||||
Ok(build_ids
|
||||
Ok(
|
||||
build_ids
|
||||
.iter()
|
||||
.map(|id| (*id, !incomplete.contains(id)))
|
||||
.collect())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if all dependency builds for a given build are completed.
|
||||
pub async fn all_deps_completed(pool: &PgPool, build_id: Uuid) -> Result<bool> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM build_dependencies bd \
|
||||
JOIN builds b ON bd.dependency_build_id = b.id \
|
||||
WHERE bd.build_id = $1 AND b.status != 'completed'",
|
||||
"SELECT COUNT(*) FROM build_dependencies bd JOIN builds b ON \
|
||||
bd.dependency_build_id = b.id WHERE bd.build_id = $1 AND b.status != \
|
||||
'completed'",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_one(pool)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{BuildProduct, CreateBuildProduct};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{BuildProduct, CreateBuildProduct},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateBuildProduct) -> Result<BuildProduct> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateBuildProduct,
|
||||
) -> Result<BuildProduct> {
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"INSERT INTO build_products (build_id, name, path, sha256_hash, file_size, content_type, is_directory) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
|
||||
"INSERT INTO build_products (build_id, name, path, sha256_hash, \
|
||||
file_size, content_type, is_directory) VALUES ($1, $2, $3, $4, $5, $6, \
|
||||
$7) RETURNING *",
|
||||
)
|
||||
.bind(input.build_id)
|
||||
.bind(&input.name)
|
||||
|
|
@ -22,14 +28,19 @@ pub async fn create(pool: &PgPool, input: CreateBuildProduct) -> Result<BuildPro
|
|||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<BuildProduct> {
|
||||
sqlx::query_as::<_, BuildProduct>("SELECT * FROM build_products WHERE id = $1")
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Build product {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_build(pool: &PgPool, build_id: Uuid) -> Result<Vec<BuildProduct>> {
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
) -> Result<Vec<BuildProduct>> {
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE build_id = $1 ORDER BY created_at ASC",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,26 +1,34 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{BuildStep, CreateBuildStep};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{BuildStep, CreateBuildStep},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateBuildStep) -> Result<BuildStep> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateBuildStep,
|
||||
) -> Result<BuildStep> {
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"INSERT INTO build_steps (build_id, step_number, command) VALUES ($1, $2, $3) RETURNING *",
|
||||
"INSERT INTO build_steps (build_id, step_number, command) VALUES ($1, $2, \
|
||||
$3) RETURNING *",
|
||||
)
|
||||
.bind(input.build_id)
|
||||
.bind(input.step_number)
|
||||
.bind(&input.command)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Build step {} already exists for this build",
|
||||
input.step_number
|
||||
))
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +40,8 @@ pub async fn complete(
|
|||
error_output: Option<&str>,
|
||||
) -> Result<BuildStep> {
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"UPDATE build_steps SET completed_at = NOW(), exit_code = $1, output = $2, error_output = $3 WHERE id = $4 RETURNING *",
|
||||
"UPDATE build_steps SET completed_at = NOW(), exit_code = $1, output = \
|
||||
$2, error_output = $3 WHERE id = $4 RETURNING *",
|
||||
)
|
||||
.bind(exit_code)
|
||||
.bind(output)
|
||||
|
|
@ -43,7 +52,10 @@ pub async fn complete(
|
|||
.ok_or_else(|| CiError::NotFound(format!("Build step {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_build(pool: &PgPool, build_id: Uuid) -> Result<Vec<BuildStep>> {
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
) -> Result<Vec<BuildStep>> {
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"SELECT * FROM build_steps WHERE build_id = $1 ORDER BY step_number ASC",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{Build, BuildStats, BuildStatus, CreateBuild};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{Build, BuildStats, BuildStatus, CreateBuild},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateBuild) -> Result<Build> {
|
||||
let is_aggregate = input.is_aggregate.unwrap_or(false);
|
||||
sqlx::query_as::<_, Build>(
|
||||
"INSERT INTO builds (evaluation_id, job_name, drv_path, status, system, outputs, is_aggregate, constituents) \
|
||||
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7) RETURNING *",
|
||||
"INSERT INTO builds (evaluation_id, job_name, drv_path, status, system, \
|
||||
outputs, is_aggregate, constituents) VALUES ($1, $2, $3, 'pending', $4, \
|
||||
$5, $6, $7) RETURNING *",
|
||||
)
|
||||
.bind(input.evaluation_id)
|
||||
.bind(&input.job_name)
|
||||
|
|
@ -19,18 +22,23 @@ pub async fn create(pool: &PgPool, input: CreateBuild) -> Result<Build> {
|
|||
.bind(&input.constituents)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Build for job '{}' already exists in this evaluation",
|
||||
input.job_name
|
||||
))
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_completed_by_drv_path(pool: &PgPool, drv_path: &str) -> Result<Option<Build>> {
|
||||
pub async fn get_completed_by_drv_path(
|
||||
pool: &PgPool,
|
||||
drv_path: &str,
|
||||
) -> Result<Option<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE drv_path = $1 AND status = 'completed' LIMIT 1",
|
||||
)
|
||||
|
|
@ -48,7 +56,10 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Build> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Build {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_evaluation(pool: &PgPool, evaluation_id: Uuid) -> Result<Vec<Build>> {
|
||||
pub async fn list_for_evaluation(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Uuid,
|
||||
) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE evaluation_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
|
|
@ -60,12 +71,9 @@ pub async fn list_for_evaluation(pool: &PgPool, evaluation_id: Uuid) -> Result<V
|
|||
|
||||
pub async fn list_pending(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT b.* FROM builds b \
|
||||
JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id \
|
||||
WHERE b.status = 'pending' \
|
||||
ORDER BY b.priority DESC, j.scheduling_shares DESC, b.created_at ASC \
|
||||
LIMIT $1",
|
||||
"SELECT b.* FROM builds b JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id WHERE b.status = 'pending' ORDER BY \
|
||||
b.priority DESC, j.scheduling_shares DESC, b.created_at ASC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
|
|
@ -77,7 +85,8 @@ pub async fn list_pending(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
|||
/// Returns `None` if the build was already claimed by another worker.
|
||||
pub async fn start(pool: &PgPool, id: Uuid) -> Result<Option<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'running', started_at = NOW() WHERE id = $1 AND status = 'pending' RETURNING *",
|
||||
"UPDATE builds SET status = 'running', started_at = NOW() WHERE id = $1 \
|
||||
AND status = 'pending' RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
|
|
@ -94,7 +103,8 @@ pub async fn complete(
|
|||
error_message: Option<&str>,
|
||||
) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = $1, completed_at = NOW(), log_path = $2, build_output_path = $3, error_message = $4 WHERE id = $5 RETURNING *",
|
||||
"UPDATE builds SET status = $1, completed_at = NOW(), log_path = $2, \
|
||||
build_output_path = $3, error_message = $4 WHERE id = $5 RETURNING *",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(log_path)
|
||||
|
|
@ -107,20 +117,23 @@ pub async fn complete(
|
|||
}
|
||||
|
||||
pub async fn list_recent(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>("SELECT * FROM builds ORDER BY created_at DESC LIMIT $1")
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds ORDER BY created_at DESC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_for_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<Build>> {
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT b.* FROM builds b \
|
||||
JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id \
|
||||
WHERE j.project_id = $1 \
|
||||
ORDER BY b.created_at DESC",
|
||||
"SELECT b.* FROM builds b JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id WHERE j.project_id = $1 ORDER BY \
|
||||
b.created_at DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
|
|
@ -136,13 +149,16 @@ pub async fn get_stats(pool: &PgPool) -> Result<BuildStats> {
|
|||
.map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Reset builds that were left in 'running' state (orphaned by a crashed runner).
|
||||
/// Limited to 50 builds per call to prevent thundering herd.
|
||||
pub async fn reset_orphaned(pool: &PgPool, older_than_secs: i64) -> Result<u64> {
|
||||
/// Reset builds that were left in 'running' state (orphaned by a crashed
|
||||
/// runner). Limited to 50 builds per call to prevent thundering herd.
|
||||
pub async fn reset_orphaned(
|
||||
pool: &PgPool,
|
||||
older_than_secs: i64,
|
||||
) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL \
|
||||
WHERE id IN (SELECT id FROM builds WHERE status = 'running' \
|
||||
AND started_at < NOW() - make_interval(secs => $1) LIMIT 50)",
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL WHERE id IN \
|
||||
(SELECT id FROM builds WHERE status = 'running' AND started_at < NOW() - \
|
||||
make_interval(secs => $1) LIMIT 50)",
|
||||
)
|
||||
.bind(older_than_secs)
|
||||
.execute(pool)
|
||||
|
|
@ -152,7 +168,8 @@ pub async fn reset_orphaned(pool: &PgPool, older_than_secs: i64) -> Result<u64>
|
|||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// List builds with optional evaluation_id, status, system, and job_name filters, with pagination.
|
||||
/// List builds with optional evaluation_id, status, system, and job_name
|
||||
/// filters, with pagination.
|
||||
pub async fn list_filtered(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Option<Uuid>,
|
||||
|
|
@ -163,12 +180,10 @@ pub async fn list_filtered(
|
|||
offset: i64,
|
||||
) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds \
|
||||
WHERE ($1::uuid IS NULL OR evaluation_id = $1) \
|
||||
AND ($2::text IS NULL OR status = $2) \
|
||||
AND ($3::text IS NULL OR system = $3) \
|
||||
AND ($4::text IS NULL OR job_name ILIKE '%' || $4 || '%') \
|
||||
ORDER BY created_at DESC LIMIT $5 OFFSET $6",
|
||||
"SELECT * FROM builds WHERE ($1::uuid IS NULL OR evaluation_id = $1) AND \
|
||||
($2::text IS NULL OR status = $2) AND ($3::text IS NULL OR system = $3) \
|
||||
AND ($4::text IS NULL OR job_name ILIKE '%' || $4 || '%') ORDER BY \
|
||||
created_at DESC LIMIT $5 OFFSET $6",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(status)
|
||||
|
|
@ -189,11 +204,9 @@ pub async fn count_filtered(
|
|||
job_name: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM builds \
|
||||
WHERE ($1::uuid IS NULL OR evaluation_id = $1) \
|
||||
AND ($2::text IS NULL OR status = $2) \
|
||||
AND ($3::text IS NULL OR system = $3) \
|
||||
AND ($4::text IS NULL OR job_name ILIKE '%' || $4 || '%')",
|
||||
"SELECT COUNT(*) FROM builds WHERE ($1::uuid IS NULL OR evaluation_id = \
|
||||
$1) AND ($2::text IS NULL OR status = $2) AND ($3::text IS NULL OR \
|
||||
system = $3) AND ($4::text IS NULL OR job_name ILIKE '%' || $4 || '%')",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(status)
|
||||
|
|
@ -207,7 +220,8 @@ pub async fn count_filtered(
|
|||
|
||||
pub async fn cancel(pool: &PgPool, id: Uuid) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'cancelled', completed_at = NOW() WHERE id = $1 AND status IN ('pending', 'running') RETURNING *",
|
||||
"UPDATE builds SET status = 'cancelled', completed_at = NOW() WHERE id = \
|
||||
$1 AND status IN ('pending', 'running') RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
|
|
@ -254,10 +268,10 @@ pub async fn cancel_cascade(pool: &PgPool, id: Uuid) -> Result<Vec<Build>> {
|
|||
/// Only works for failed, completed, or cancelled builds.
|
||||
pub async fn restart(pool: &PgPool, id: Uuid) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL, completed_at = NULL, \
|
||||
log_path = NULL, build_output_path = NULL, error_message = NULL, \
|
||||
retry_count = retry_count + 1 \
|
||||
WHERE id = $1 AND status IN ('failed', 'completed', 'cancelled') RETURNING *",
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL, completed_at = \
|
||||
NULL, log_path = NULL, build_output_path = NULL, error_message = NULL, \
|
||||
retry_count = retry_count + 1 WHERE id = $1 AND status IN ('failed', \
|
||||
'completed', 'cancelled') RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
|
|
@ -289,23 +303,28 @@ pub async fn get_completed_by_drv_paths(
|
|||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
let builds = sqlx::query_as::<_, Build>(
|
||||
"SELECT DISTINCT ON (drv_path) * FROM builds \
|
||||
WHERE drv_path = ANY($1) AND status = 'completed' \
|
||||
ORDER BY drv_path, completed_at DESC",
|
||||
"SELECT DISTINCT ON (drv_path) * FROM builds WHERE drv_path = ANY($1) AND \
|
||||
status = 'completed' ORDER BY drv_path, completed_at DESC",
|
||||
)
|
||||
.bind(drv_paths)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
Ok(builds
|
||||
Ok(
|
||||
builds
|
||||
.into_iter()
|
||||
.map(|b| (b.drv_path.clone(), b))
|
||||
.collect())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Set the builder_id for a build.
|
||||
pub async fn set_builder(pool: &PgPool, id: Uuid, builder_id: Uuid) -> Result<()> {
|
||||
pub async fn set_builder(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
builder_id: Uuid,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE builds SET builder_id = $1 WHERE id = $2")
|
||||
.bind(builder_id)
|
||||
.bind(id)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,31 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{Channel, CreateChannel};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{Channel, CreateChannel},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateChannel) -> Result<Channel> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"INSERT INTO channels (project_id, name, jobset_id) \
|
||||
VALUES ($1, $2, $3) RETURNING *",
|
||||
"INSERT INTO channels (project_id, name, jobset_id) VALUES ($1, $2, $3) \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
.bind(input.jobset_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => CiError::Conflict(
|
||||
format!("Channel '{}' already exists for this project", input.name),
|
||||
),
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Channel '{}' already exists for this project",
|
||||
input.name
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -30,8 +37,13 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Channel> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Channel {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<Channel>> {
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE project_id = $1 ORDER BY name")
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Channel>> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"SELECT * FROM channels WHERE project_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
|
|
@ -46,10 +58,14 @@ pub async fn list_all(pool: &PgPool) -> Result<Vec<Channel>> {
|
|||
}
|
||||
|
||||
/// Promote an evaluation to a channel (set it as the current evaluation).
|
||||
pub async fn promote(pool: &PgPool, channel_id: Uuid, evaluation_id: Uuid) -> Result<Channel> {
|
||||
pub async fn promote(
|
||||
pool: &PgPool,
|
||||
channel_id: Uuid,
|
||||
evaluation_id: Uuid,
|
||||
) -> Result<Channel> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"UPDATE channels SET current_evaluation_id = $1, updated_at = NOW() \
|
||||
WHERE id = $2 RETURNING *",
|
||||
"UPDATE channels SET current_evaluation_id = $1, updated_at = NOW() WHERE \
|
||||
id = $2 RETURNING *",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(channel_id)
|
||||
|
|
@ -70,7 +86,8 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the channel for a jobset and auto-promote if all builds in the evaluation succeeded.
|
||||
/// Find the channel for a jobset and auto-promote if all builds in the
|
||||
/// evaluation succeeded.
|
||||
pub async fn auto_promote_if_complete(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -78,8 +95,8 @@ pub async fn auto_promote_if_complete(
|
|||
) -> Result<()> {
|
||||
// Check if all builds for this evaluation are completed
|
||||
let row: (i64, i64) = sqlx::query_as(
|
||||
"SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'completed') \
|
||||
FROM builds WHERE evaluation_id = $1",
|
||||
"SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'completed') FROM \
|
||||
builds WHERE evaluation_id = $1",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.fetch_one(pool)
|
||||
|
|
@ -92,7 +109,8 @@ pub async fn auto_promote_if_complete(
|
|||
}
|
||||
|
||||
// All builds completed — promote to any channels tracking this jobset
|
||||
let channels = sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE jobset_id = $1")
|
||||
let channels =
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE jobset_id = $1")
|
||||
.bind(jobset_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1,25 +1,33 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateEvaluation, Evaluation, EvaluationStatus};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateEvaluation, Evaluation, EvaluationStatus},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateEvaluation) -> Result<Evaluation> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateEvaluation,
|
||||
) -> Result<Evaluation> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"INSERT INTO evaluations (jobset_id, commit_hash, status) VALUES ($1, $2, 'pending') RETURNING *",
|
||||
"INSERT INTO evaluations (jobset_id, commit_hash, status) VALUES ($1, $2, \
|
||||
'pending') RETURNING *",
|
||||
)
|
||||
.bind(input.jobset_id)
|
||||
.bind(&input.commit_hash)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Evaluation for commit '{}' already exists in this jobset",
|
||||
input.commit_hash
|
||||
))
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -31,9 +39,13 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Evaluation> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Evaluation {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_jobset(pool: &PgPool, jobset_id: Uuid) -> Result<Vec<Evaluation>> {
|
||||
pub async fn list_for_jobset(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
) -> Result<Vec<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 ORDER BY evaluation_time DESC",
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 ORDER BY evaluation_time \
|
||||
DESC",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.fetch_all(pool)
|
||||
|
|
@ -41,7 +53,8 @@ pub async fn list_for_jobset(pool: &PgPool, jobset_id: Uuid) -> Result<Vec<Evalu
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List evaluations with optional jobset_id and status filters, with pagination.
|
||||
/// List evaluations with optional jobset_id and status filters, with
|
||||
/// pagination.
|
||||
pub async fn list_filtered(
|
||||
pool: &PgPool,
|
||||
jobset_id: Option<Uuid>,
|
||||
|
|
@ -50,10 +63,9 @@ pub async fn list_filtered(
|
|||
offset: i64,
|
||||
) -> Result<Vec<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations \
|
||||
WHERE ($1::uuid IS NULL OR jobset_id = $1) \
|
||||
AND ($2::text IS NULL OR status = $2) \
|
||||
ORDER BY evaluation_time DESC LIMIT $3 OFFSET $4",
|
||||
"SELECT * FROM evaluations WHERE ($1::uuid IS NULL OR jobset_id = $1) AND \
|
||||
($2::text IS NULL OR status = $2) ORDER BY evaluation_time DESC LIMIT $3 \
|
||||
OFFSET $4",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(status)
|
||||
|
|
@ -70,9 +82,8 @@ pub async fn count_filtered(
|
|||
status: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM evaluations \
|
||||
WHERE ($1::uuid IS NULL OR jobset_id = $1) \
|
||||
AND ($2::text IS NULL OR status = $2)",
|
||||
"SELECT COUNT(*) FROM evaluations WHERE ($1::uuid IS NULL OR jobset_id = \
|
||||
$1) AND ($2::text IS NULL OR status = $2)",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(status)
|
||||
|
|
@ -89,7 +100,8 @@ pub async fn update_status(
|
|||
error_message: Option<&str>,
|
||||
) -> Result<Evaluation> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"UPDATE evaluations SET status = $1, error_message = $2 WHERE id = $3 RETURNING *",
|
||||
"UPDATE evaluations SET status = $1, error_message = $2 WHERE id = $3 \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(error_message)
|
||||
|
|
@ -99,9 +111,13 @@ pub async fn update_status(
|
|||
.ok_or_else(|| CiError::NotFound(format!("Evaluation {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn get_latest(pool: &PgPool, jobset_id: Uuid) -> Result<Option<Evaluation>> {
|
||||
pub async fn get_latest(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
) -> Result<Option<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 ORDER BY evaluation_time DESC LIMIT 1",
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 ORDER BY evaluation_time \
|
||||
DESC LIMIT 1",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.fetch_optional(pool)
|
||||
|
|
@ -110,7 +126,11 @@ pub async fn get_latest(pool: &PgPool, jobset_id: Uuid) -> Result<Option<Evaluat
|
|||
}
|
||||
|
||||
/// Set the inputs hash for an evaluation (used for eval caching).
|
||||
pub async fn set_inputs_hash(pool: &PgPool, id: Uuid, hash: &str) -> Result<()> {
|
||||
pub async fn set_inputs_hash(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
hash: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE evaluations SET inputs_hash = $1 WHERE id = $2")
|
||||
.bind(hash)
|
||||
.bind(id)
|
||||
|
|
@ -120,15 +140,16 @@ pub async fn set_inputs_hash(pool: &PgPool, id: Uuid, hash: &str) -> Result<()>
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an evaluation with the same inputs_hash already exists for this jobset.
|
||||
/// Check if an evaluation with the same inputs_hash already exists for this
|
||||
/// jobset.
|
||||
pub async fn get_by_inputs_hash(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
inputs_hash: &str,
|
||||
) -> Result<Option<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 AND inputs_hash = $2 \
|
||||
AND status = 'completed' ORDER BY evaluation_time DESC LIMIT 1",
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 AND inputs_hash = $2 AND \
|
||||
status = 'completed' ORDER BY evaluation_time DESC LIMIT 1",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(inputs_hash)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::JobsetInput;
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::JobsetInput,
|
||||
};
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
|
|
@ -13,7 +15,8 @@ pub async fn create(
|
|||
revision: Option<&str>,
|
||||
) -> Result<JobsetInput> {
|
||||
sqlx::query_as::<_, JobsetInput>(
|
||||
"INSERT INTO jobset_inputs (jobset_id, name, input_type, value, revision) VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||
"INSERT INTO jobset_inputs (jobset_id, name, input_type, value, revision) \
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(name)
|
||||
|
|
@ -22,15 +25,22 @@ pub async fn create(
|
|||
.bind(revision)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Input '{name}' already exists in this jobset"))
|
||||
}
|
||||
CiError::Conflict(format!(
|
||||
"Input '{name}' already exists in this jobset"
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_for_jobset(pool: &PgPool, jobset_id: Uuid) -> Result<Vec<JobsetInput>> {
|
||||
pub async fn list_for_jobset(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
) -> Result<Vec<JobsetInput>> {
|
||||
sqlx::query_as::<_, JobsetInput>(
|
||||
"SELECT * FROM jobset_inputs WHERE jobset_id = $1 ORDER BY name ASC",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{ActiveJobset, CreateJobset, Jobset, UpdateJobset};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{ActiveJobset, CreateJobset, Jobset, UpdateJobset},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
||||
let enabled = input.enabled.unwrap_or(true);
|
||||
|
|
@ -11,7 +13,9 @@ pub async fn create(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
|||
let scheduling_shares = input.scheduling_shares.unwrap_or(100);
|
||||
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"INSERT INTO jobsets (project_id, name, nix_expression, enabled, flake_mode, check_interval, branch, scheduling_shares) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
|
||||
"INSERT INTO jobsets (project_id, name, nix_expression, enabled, \
|
||||
flake_mode, check_interval, branch, scheduling_shares) VALUES ($1, $2, \
|
||||
$3, $4, $5, $6, $7, $8) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
|
|
@ -23,11 +27,16 @@ pub async fn create(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
|||
.bind(scheduling_shares)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Jobset '{}' already exists in this project", input.name))
|
||||
}
|
||||
CiError::Conflict(format!(
|
||||
"Jobset '{}' already exists in this project",
|
||||
input.name
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +55,8 @@ pub async fn list_for_project(
|
|||
offset: i64,
|
||||
) -> Result<Vec<Jobset>> {
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"SELECT * FROM jobsets WHERE project_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
"SELECT * FROM jobsets WHERE project_id = $1 ORDER BY created_at DESC \
|
||||
LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(limit)
|
||||
|
|
@ -57,7 +67,8 @@ pub async fn list_for_project(
|
|||
}
|
||||
|
||||
pub async fn count_for_project(pool: &PgPool, project_id: Uuid) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobsets WHERE project_id = $1")
|
||||
let row: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM jobsets WHERE project_id = $1")
|
||||
.bind(project_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
|
|
@ -65,7 +76,11 @@ pub async fn count_for_project(pool: &PgPool, project_id: Uuid) -> Result<i64> {
|
|||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, input: UpdateJobset) -> Result<Jobset> {
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
input: UpdateJobset,
|
||||
) -> Result<Jobset> {
|
||||
let existing = get(pool, id).await?;
|
||||
|
||||
let name = input.name.unwrap_or(existing.name);
|
||||
|
|
@ -79,7 +94,9 @@ pub async fn update(pool: &PgPool, id: Uuid, input: UpdateJobset) -> Result<Jobs
|
|||
.unwrap_or(existing.scheduling_shares);
|
||||
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"UPDATE jobsets SET name = $1, nix_expression = $2, enabled = $3, flake_mode = $4, check_interval = $5, branch = $6, scheduling_shares = $7 WHERE id = $8 RETURNING *",
|
||||
"UPDATE jobsets SET name = $1, nix_expression = $2, enabled = $3, \
|
||||
flake_mode = $4, check_interval = $5, branch = $6, scheduling_shares = \
|
||||
$7 WHERE id = $8 RETURNING *",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&nix_expression)
|
||||
|
|
@ -91,11 +108,15 @@ pub async fn update(pool: &PgPool, id: Uuid, input: UpdateJobset) -> Result<Jobs
|
|||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Jobset '{name}' already exists in this project"))
|
||||
}
|
||||
CiError::Conflict(format!(
|
||||
"Jobset '{name}' already exists in this project"
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -119,16 +140,13 @@ pub async fn upsert(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
|||
let scheduling_shares = input.scheduling_shares.unwrap_or(100);
|
||||
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"INSERT INTO jobsets (project_id, name, nix_expression, enabled, flake_mode, check_interval, branch, scheduling_shares) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
ON CONFLICT (project_id, name) DO UPDATE SET \
|
||||
nix_expression = EXCLUDED.nix_expression, \
|
||||
enabled = EXCLUDED.enabled, \
|
||||
flake_mode = EXCLUDED.flake_mode, \
|
||||
check_interval = EXCLUDED.check_interval, \
|
||||
branch = EXCLUDED.branch, \
|
||||
scheduling_shares = EXCLUDED.scheduling_shares \
|
||||
RETURNING *",
|
||||
"INSERT INTO jobsets (project_id, name, nix_expression, enabled, \
|
||||
flake_mode, check_interval, branch, scheduling_shares) VALUES ($1, $2, \
|
||||
$3, $4, $5, $6, $7, $8) ON CONFLICT (project_id, name) DO UPDATE SET \
|
||||
nix_expression = EXCLUDED.nix_expression, enabled = EXCLUDED.enabled, \
|
||||
flake_mode = EXCLUDED.flake_mode, check_interval = \
|
||||
EXCLUDED.check_interval, branch = EXCLUDED.branch, scheduling_shares = \
|
||||
EXCLUDED.scheduling_shares RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,44 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateNotificationConfig, NotificationConfig};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateNotificationConfig, NotificationConfig},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateNotificationConfig) -> Result<NotificationConfig> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateNotificationConfig,
|
||||
) -> Result<NotificationConfig> {
|
||||
sqlx::query_as::<_, NotificationConfig>(
|
||||
"INSERT INTO notification_configs (project_id, notification_type, config) VALUES ($1, $2, $3) RETURNING *",
|
||||
"INSERT INTO notification_configs (project_id, notification_type, config) \
|
||||
VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.notification_type)
|
||||
.bind(&input.config)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Notification config '{}' already exists for this project",
|
||||
input.notification_type
|
||||
))
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_for_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<NotificationConfig>> {
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<NotificationConfig>> {
|
||||
sqlx::query_as::<_, NotificationConfig>(
|
||||
"SELECT * FROM notification_configs WHERE project_id = $1 AND enabled = true ORDER BY created_at DESC",
|
||||
"SELECT * FROM notification_configs WHERE project_id = $1 AND enabled = \
|
||||
true ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateProject, Project, UpdateProject};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateProject, Project, UpdateProject},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateProject) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, $3) RETURNING *",
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, \
|
||||
$3) RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.repository_url)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Project '{}' already exists", input.name))
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +42,11 @@ pub async fn get_by_name(pool: &PgPool, name: &str) -> Result<Project> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Project '{name}' not found")))
|
||||
}
|
||||
|
||||
pub async fn list(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<Project>> {
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Project>> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"SELECT * FROM projects ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
)
|
||||
|
|
@ -56,7 +65,11 @@ pub async fn count(pool: &PgPool) -> Result<i64> {
|
|||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, input: UpdateProject) -> Result<Project> {
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
input: UpdateProject,
|
||||
) -> Result<Project> {
|
||||
// Build dynamic update — only set provided fields
|
||||
let existing = get(pool, id).await?;
|
||||
|
||||
|
|
@ -65,7 +78,8 @@ pub async fn update(pool: &PgPool, id: Uuid, input: UpdateProject) -> Result<Pro
|
|||
let repository_url = input.repository_url.unwrap_or(existing.repository_url);
|
||||
|
||||
sqlx::query_as::<_, Project>(
|
||||
"UPDATE projects SET name = $1, description = $2, repository_url = $3 WHERE id = $4 RETURNING *",
|
||||
"UPDATE projects SET name = $1, description = $2, repository_url = $3 \
|
||||
WHERE id = $4 RETURNING *",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&description)
|
||||
|
|
@ -73,21 +87,21 @@ pub async fn update(pool: &PgPool, id: Uuid, input: UpdateProject) -> Result<Pro
|
|||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Project '{name}' already exists"))
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &PgPool, input: CreateProject) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (name) DO UPDATE SET \
|
||||
description = EXCLUDED.description, \
|
||||
repository_url = EXCLUDED.repository_url \
|
||||
RETURNING *",
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, \
|
||||
$3) ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description, \
|
||||
repository_url = EXCLUDED.repository_url RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.description)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateRemoteBuilder, RemoteBuilder};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateRemoteBuilder, RemoteBuilder},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateRemoteBuilder) -> Result<RemoteBuilder> {
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateRemoteBuilder,
|
||||
) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"INSERT INTO remote_builders (name, ssh_uri, systems, max_jobs, speed_factor, \
|
||||
supported_features, mandatory_features, public_host_key, ssh_key_file) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *",
|
||||
"INSERT INTO remote_builders (name, ssh_uri, systems, max_jobs, \
|
||||
speed_factor, supported_features, mandatory_features, public_host_key, \
|
||||
ssh_key_file) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.ssh_uri)
|
||||
|
|
@ -21,16 +26,23 @@ pub async fn create(pool: &PgPool, input: CreateRemoteBuilder) -> Result<RemoteB
|
|||
.bind(&input.ssh_key_file)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Remote builder '{}' already exists", input.name))
|
||||
}
|
||||
CiError::Conflict(format!(
|
||||
"Remote builder '{}' already exists",
|
||||
input.name
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>("SELECT * FROM remote_builders WHERE id = $1")
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
|
|
@ -48,7 +60,8 @@ pub async fn list(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
|||
|
||||
pub async fn list_enabled(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE enabled = true ORDER BY speed_factor DESC, name",
|
||||
"SELECT * FROM remote_builders WHERE enabled = true ORDER BY speed_factor \
|
||||
DESC, name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
|
|
@ -56,7 +69,10 @@ pub async fn list_enabled(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
|||
}
|
||||
|
||||
/// Find a suitable builder for the given system.
|
||||
pub async fn find_for_system(pool: &PgPool, system: &str) -> Result<Vec<RemoteBuilder>> {
|
||||
pub async fn find_for_system(
|
||||
pool: &PgPool,
|
||||
system: &str,
|
||||
) -> Result<Vec<RemoteBuilder>> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE enabled = true AND $1 = ANY(systems) \
|
||||
ORDER BY speed_factor DESC",
|
||||
|
|
@ -74,18 +90,13 @@ pub async fn update(
|
|||
) -> Result<RemoteBuilder> {
|
||||
// Build dynamic update — use 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 = COALESCE($4, max_jobs), \
|
||||
speed_factor = COALESCE($5, speed_factor), \
|
||||
"UPDATE remote_builders SET name = COALESCE($1, name), ssh_uri = \
|
||||
COALESCE($2, ssh_uri), systems = COALESCE($3, systems), max_jobs = \
|
||||
COALESCE($4, max_jobs), speed_factor = COALESCE($5, speed_factor), \
|
||||
supported_features = COALESCE($6, supported_features), \
|
||||
mandatory_features = COALESCE($7, mandatory_features), \
|
||||
enabled = COALESCE($8, enabled), \
|
||||
public_host_key = COALESCE($9, public_host_key), \
|
||||
ssh_key_file = COALESCE($10, ssh_key_file) \
|
||||
WHERE id = $11 RETURNING *",
|
||||
mandatory_features = COALESCE($7, mandatory_features), enabled = \
|
||||
COALESCE($8, enabled), public_host_key = COALESCE($9, public_host_key), \
|
||||
ssh_key_file = COALESCE($10, ssh_key_file) WHERE id = $11 RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.ssh_uri)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateWebhookConfig, WebhookConfig};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateWebhookConfig, WebhookConfig},
|
||||
};
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
|
|
@ -10,35 +12,44 @@ pub async fn create(
|
|||
secret_hash: Option<&str>,
|
||||
) -> Result<WebhookConfig> {
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"INSERT INTO webhook_configs (project_id, forge_type, secret_hash) VALUES ($1, $2, $3) RETURNING *",
|
||||
"INSERT INTO webhook_configs (project_id, forge_type, secret_hash) VALUES \
|
||||
($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.forge_type)
|
||||
.bind(secret_hash)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Webhook config for forge '{}' already exists for this project",
|
||||
input.forge_type
|
||||
))
|
||||
}
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<WebhookConfig> {
|
||||
sqlx::query_as::<_, WebhookConfig>("SELECT * FROM webhook_configs WHERE id = $1")
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Webhook config {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<WebhookConfig>> {
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<WebhookConfig>> {
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE project_id = $1 ORDER BY created_at DESC",
|
||||
"SELECT * FROM webhook_configs WHERE project_id = $1 ORDER BY created_at \
|
||||
DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
|
|
@ -52,7 +63,8 @@ pub async fn get_by_project_and_forge(
|
|||
forge_type: &str,
|
||||
) -> Result<Option<WebhookConfig>> {
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE project_id = $1 AND forge_type = $2 AND enabled = true",
|
||||
"SELECT * FROM webhook_configs WHERE project_id = $1 AND forge_type = $2 \
|
||||
AND enabled = true",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(forge_type)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! Tracing initialization helper for all FC daemons.
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::fmt;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
use crate::config::TracingConfig;
|
||||
|
||||
|
|
@ -10,8 +9,8 @@ use crate::config::TracingConfig;
|
|||
/// Respects `RUST_LOG` environment variable as an override. If `RUST_LOG` is
|
||||
/// not set, falls back to the configured level.
|
||||
pub fn init_tracing(config: &TracingConfig) {
|
||||
let env_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.level));
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(&config.level));
|
||||
|
||||
match config.format.as_str() {
|
||||
"json" => {
|
||||
|
|
@ -24,7 +23,7 @@ pub fn init_tracing(config: &TracingConfig) {
|
|||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
}
|
||||
},
|
||||
"full" => {
|
||||
let builder = fmt()
|
||||
.with_target(config.show_targets)
|
||||
|
|
@ -34,7 +33,7 @@ pub fn init_tracing(config: &TracingConfig) {
|
|||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// "compact" or any other value
|
||||
let builder = fmt()
|
||||
|
|
@ -46,6 +45,6 @@ pub fn init_tracing(config: &TracingConfig) {
|
|||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
//! Input validation helpers
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// Validate that a path is a valid nix store path.
|
||||
/// Rejects path traversal, overly long paths, and non-store paths.
|
||||
pub fn is_valid_store_path(path: &str) -> bool {
|
||||
path.starts_with("/nix/store/") && !path.contains("..") && path.len() < 512
|
||||
}
|
||||
|
||||
/// Validate that a string is a valid nix store hash (32 lowercase alphanumeric chars).
|
||||
/// Validate that a string is a valid nix store hash (32 lowercase alphanumeric
|
||||
/// chars).
|
||||
pub fn is_valid_nix_hash(hash: &str) -> bool {
|
||||
hash.len() == 32
|
||||
&& hash
|
||||
|
|
@ -25,9 +27,11 @@ static NAME_RE: LazyLock<Regex> =
|
|||
static COMMIT_HASH_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[0-9a-fA-F]{1,64}$").unwrap());
|
||||
|
||||
static SYSTEM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\w+-\w+$").unwrap());
|
||||
static SYSTEM_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^\w+-\w+$").unwrap());
|
||||
|
||||
const VALID_REPO_PREFIXES: &[&str] = &["https://", "http://", "git://", "ssh://"];
|
||||
const VALID_REPO_PREFIXES: &[&str] =
|
||||
&["https://", "http://", "git://", "ssh://"];
|
||||
const VALID_FORGE_TYPES: &[&str] = &["github", "gitea", "forgejo", "gitlab"];
|
||||
|
||||
/// Trait for validating request DTOs before persisting.
|
||||
|
|
@ -56,7 +60,8 @@ fn validate_repository_url(url: &str) -> Result<(), String> {
|
|||
}
|
||||
if !VALID_REPO_PREFIXES.iter().any(|p| url.starts_with(p)) {
|
||||
return Err(
|
||||
"repository_url must start with https://, http://, git://, or ssh://".to_string(),
|
||||
"repository_url must start with https://, http://, git://, or ssh://"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -281,9 +286,10 @@ impl Validate for CreateWebhookConfig {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
// --- is_valid_store_path ---
|
||||
|
||||
#[test]
|
||||
|
|
@ -326,8 +332,9 @@ 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 for it.
|
||||
// "/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
|
||||
// for it.
|
||||
assert!(is_valid_store_path("/nix/store/"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
//! Database integration tests
|
||||
|
||||
use fc_common::config::DatabaseConfig;
|
||||
use fc_common::*;
|
||||
use fc_common::{config::DatabaseConfig, *};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_database_connection() -> anyhow::Result<()> {
|
||||
let config = DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test".to_string(),
|
||||
url: "postgresql://postgres:password@localhost/test"
|
||||
.to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
|
|
@ -20,11 +20,12 @@ async fn test_database_connection() -> anyhow::Result<()> {
|
|||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_database_connection: no PostgreSQL instance available - {}",
|
||||
"Skipping test_database_connection: no PostgreSQL instance available \
|
||||
- {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Test health check
|
||||
|
|
@ -48,15 +49,20 @@ async fn test_database_connection() -> anyhow::Result<()> {
|
|||
#[tokio::test]
|
||||
async fn test_database_health_check() -> anyhow::Result<()> {
|
||||
// Try to connect, skip test if database is not available
|
||||
let pool = match PgPool::connect("postgresql://postgres:password@localhost/test").await {
|
||||
let pool = match PgPool::connect(
|
||||
"postgresql://postgres:password@localhost/test",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_database_health_check: no PostgreSQL instance available - {}",
|
||||
"Skipping test_database_health_check: no PostgreSQL instance \
|
||||
available - {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Should succeed
|
||||
|
|
@ -69,7 +75,11 @@ async fn test_database_health_check() -> anyhow::Result<()> {
|
|||
#[tokio::test]
|
||||
async fn test_connection_info() -> anyhow::Result<()> {
|
||||
// Try to connect, skip test if database is not available
|
||||
let pool = match PgPool::connect("postgresql://postgres:password@localhost/test").await {
|
||||
let pool = match PgPool::connect(
|
||||
"postgresql://postgres:password@localhost/test",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
println!(
|
||||
|
|
@ -77,11 +87,12 @@ async fn test_connection_info() -> anyhow::Result<()> {
|
|||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let db = match Database::new(DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test".to_string(),
|
||||
url: "postgresql://postgres:password@localhost/test"
|
||||
.to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
|
|
@ -98,7 +109,7 @@ async fn test_connection_info() -> anyhow::Result<()> {
|
|||
);
|
||||
pool.close().await;
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let info = db.get_connection_info().await?;
|
||||
|
|
@ -117,7 +128,8 @@ async fn test_connection_info() -> anyhow::Result<()> {
|
|||
#[tokio::test]
|
||||
async fn test_pool_stats() -> anyhow::Result<()> {
|
||||
let db = match Database::new(DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test".to_string(),
|
||||
url: "postgresql://postgres:password@localhost/test"
|
||||
.to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
|
|
@ -133,7 +145,7 @@ async fn test_pool_stats() -> anyhow::Result<()> {
|
|||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let stats = db.get_pool_stats().await;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
//! Integration tests for database and configuration
|
||||
|
||||
use fc_common::Database;
|
||||
use fc_common::config::{Config, DatabaseConfig};
|
||||
use fc_common::{
|
||||
Database,
|
||||
config::{Config, DatabaseConfig},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_database_connection_full() -> anyhow::Result<()> {
|
||||
// This test requires a running PostgreSQL instance
|
||||
// Skip if no database is available
|
||||
let config = DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/fc_ci_test".to_string(),
|
||||
url: "postgresql://postgres:password@localhost/fc_ci_test"
|
||||
.to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
|
|
@ -22,7 +25,7 @@ async fn test_database_connection_full() -> anyhow::Result<()> {
|
|||
Err(_) => {
|
||||
println!("Skipping database test: no PostgreSQL instance available");
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Test health check
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
//! Integration tests for repository CRUD operations.
|
||||
//! Requires TEST_DATABASE_URL to be set to a PostgreSQL connection string.
|
||||
|
||||
use fc_common::models::*;
|
||||
use fc_common::repo;
|
||||
use fc_common::{models::*, repo};
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
|
|
@ -10,7 +9,7 @@ async fn get_pool() -> Option<sqlx::PgPool> {
|
|||
Err(_) => {
|
||||
println!("Skipping repo test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -27,23 +26,21 @@ async fn get_pool() -> Option<sqlx::PgPool> {
|
|||
|
||||
/// Helper: create a project with a unique name.
|
||||
async fn create_test_project(pool: &sqlx::PgPool, prefix: &str) -> Project {
|
||||
repo::projects::create(
|
||||
pool,
|
||||
CreateProject {
|
||||
repo::projects::create(pool, CreateProject {
|
||||
name: format!("{prefix}-{}", uuid::Uuid::new_v4()),
|
||||
description: Some("Test project".to_string()),
|
||||
repository_url: "https://github.com/test/repo".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create project")
|
||||
}
|
||||
|
||||
/// Helper: create a jobset for a project.
|
||||
async fn create_test_jobset(pool: &sqlx::PgPool, project_id: uuid::Uuid) -> Jobset {
|
||||
repo::jobsets::create(
|
||||
pool,
|
||||
CreateJobset {
|
||||
async fn create_test_jobset(
|
||||
pool: &sqlx::PgPool,
|
||||
project_id: uuid::Uuid,
|
||||
) -> Jobset {
|
||||
repo::jobsets::create(pool, CreateJobset {
|
||||
project_id,
|
||||
name: format!("default-{}", uuid::Uuid::new_v4()),
|
||||
nix_expression: "packages".to_string(),
|
||||
|
|
@ -52,21 +49,20 @@ async fn create_test_jobset(pool: &sqlx::PgPool, project_id: uuid::Uuid) -> Jobs
|
|||
check_interval: None,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create jobset")
|
||||
}
|
||||
|
||||
/// Helper: create an evaluation for a jobset.
|
||||
async fn create_test_eval(pool: &sqlx::PgPool, jobset_id: uuid::Uuid) -> Evaluation {
|
||||
repo::evaluations::create(
|
||||
pool,
|
||||
CreateEvaluation {
|
||||
async fn create_test_eval(
|
||||
pool: &sqlx::PgPool,
|
||||
jobset_id: uuid::Uuid,
|
||||
) -> Evaluation {
|
||||
repo::evaluations::create(pool, CreateEvaluation {
|
||||
jobset_id,
|
||||
commit_hash: format!("abc123{}", uuid::Uuid::new_v4().simple()),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create evaluation")
|
||||
}
|
||||
|
|
@ -79,9 +75,7 @@ async fn create_test_build(
|
|||
drv_path: &str,
|
||||
system: Option<&str>,
|
||||
) -> Build {
|
||||
repo::builds::create(
|
||||
pool,
|
||||
CreateBuild {
|
||||
repo::builds::create(pool, CreateBuild {
|
||||
evaluation_id: eval_id,
|
||||
job_name: job_name.to_string(),
|
||||
drv_path: drv_path.to_string(),
|
||||
|
|
@ -89,8 +83,7 @@ async fn create_test_build(
|
|||
outputs: None,
|
||||
is_aggregate: None,
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create build")
|
||||
}
|
||||
|
|
@ -122,15 +115,11 @@ async fn test_project_crud() {
|
|||
assert_eq!(by_name.id, project.id);
|
||||
|
||||
// Update
|
||||
let updated = repo::projects::update(
|
||||
&pool,
|
||||
project.id,
|
||||
UpdateProject {
|
||||
let updated = repo::projects::update(&pool, project.id, UpdateProject {
|
||||
name: None,
|
||||
description: Some("Updated description".to_string()),
|
||||
repository_url: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("update project");
|
||||
assert_eq!(updated.description.as_deref(), Some("Updated description"));
|
||||
|
|
@ -160,26 +149,20 @@ async fn test_project_unique_constraint() {
|
|||
|
||||
let name = format!("unique-test-{}", uuid::Uuid::new_v4());
|
||||
|
||||
let _project = repo::projects::create(
|
||||
&pool,
|
||||
CreateProject {
|
||||
let _project = repo::projects::create(&pool, CreateProject {
|
||||
name: name.clone(),
|
||||
description: None,
|
||||
repository_url: "https://github.com/test/repo".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create first project");
|
||||
|
||||
// Creating with same name should fail with Conflict
|
||||
let result = repo::projects::create(
|
||||
&pool,
|
||||
CreateProject {
|
||||
let result = repo::projects::create(&pool, CreateProject {
|
||||
name,
|
||||
description: None,
|
||||
repository_url: "https://github.com/test/repo2".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(fc_common::CiError::Conflict(_))));
|
||||
|
|
@ -195,9 +178,7 @@ async fn test_jobset_crud() {
|
|||
let project = create_test_project(&pool, "jobset").await;
|
||||
|
||||
// Create jobset
|
||||
let jobset = repo::jobsets::create(
|
||||
&pool,
|
||||
CreateJobset {
|
||||
let jobset = repo::jobsets::create(&pool, CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "default".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
|
|
@ -206,8 +187,7 @@ async fn test_jobset_crud() {
|
|||
check_interval: None,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
|
|
@ -227,10 +207,7 @@ async fn test_jobset_crud() {
|
|||
assert_eq!(jobsets.len(), 1);
|
||||
|
||||
// Update
|
||||
let updated = repo::jobsets::update(
|
||||
&pool,
|
||||
jobset.id,
|
||||
UpdateJobset {
|
||||
let updated = repo::jobsets::update(&pool, jobset.id, UpdateJobset {
|
||||
name: None,
|
||||
nix_expression: Some("checks".to_string()),
|
||||
enabled: Some(false),
|
||||
|
|
@ -238,8 +215,7 @@ async fn test_jobset_crud() {
|
|||
check_interval: None,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("update jobset");
|
||||
assert_eq!(updated.nix_expression, "checks");
|
||||
|
|
@ -266,20 +242,22 @@ async fn test_evaluation_and_build_lifecycle() {
|
|||
let jobset = create_test_jobset(&pool, project.id).await;
|
||||
|
||||
// Create evaluation
|
||||
let eval = repo::evaluations::create(
|
||||
&pool,
|
||||
CreateEvaluation {
|
||||
let eval = repo::evaluations::create(&pool, CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: "abc123def456".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create evaluation");
|
||||
|
||||
assert_eq!(eval.commit_hash, "abc123def456");
|
||||
|
||||
// Update status
|
||||
let updated = repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Running, None)
|
||||
let updated = repo::evaluations::update_status(
|
||||
&pool,
|
||||
eval.id,
|
||||
EvaluationStatus::Running,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("update evaluation status");
|
||||
assert!(matches!(updated.status, EvaluationStatus::Running));
|
||||
|
|
@ -335,27 +313,23 @@ async fn test_evaluation_and_build_lifecycle() {
|
|||
assert!(matches!(completed.status, BuildStatus::Completed));
|
||||
|
||||
// Create build step
|
||||
let step = repo::build_steps::create(
|
||||
&pool,
|
||||
CreateBuildStep {
|
||||
let step = repo::build_steps::create(&pool, CreateBuildStep {
|
||||
build_id: build.id,
|
||||
step_number: 1,
|
||||
command: "nix build".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create build step");
|
||||
|
||||
// Complete build step
|
||||
let completed_step = repo::build_steps::complete(&pool, step.id, 0, Some("output"), None)
|
||||
let completed_step =
|
||||
repo::build_steps::complete(&pool, step.id, 0, Some("output"), None)
|
||||
.await
|
||||
.expect("complete build step");
|
||||
assert_eq!(completed_step.exit_code, Some(0));
|
||||
|
||||
// Create build product
|
||||
let product = repo::build_products::create(
|
||||
&pool,
|
||||
CreateBuildProduct {
|
||||
let product = repo::build_products::create(&pool, CreateBuildProduct {
|
||||
build_id: build.id,
|
||||
name: "hello".to_string(),
|
||||
path: "/nix/store/output".to_string(),
|
||||
|
|
@ -363,8 +337,7 @@ async fn test_evaluation_and_build_lifecycle() {
|
|||
file_size: Some(1024),
|
||||
content_type: None,
|
||||
is_directory: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create build product");
|
||||
assert_eq!(product.file_size, Some(1024));
|
||||
|
|
@ -382,7 +355,8 @@ async fn test_evaluation_and_build_lifecycle() {
|
|||
assert_eq!(steps.len(), 1);
|
||||
|
||||
// Test filtered list
|
||||
let filtered = repo::builds::list_filtered(&pool, Some(eval.id), None, None, None, 50, 0)
|
||||
let filtered =
|
||||
repo::builds::list_filtered(&pool, Some(eval.id), None, None, None, 50, 0)
|
||||
.await
|
||||
.expect("list filtered");
|
||||
assert!(filtered.iter().any(|b| b.id == build.id));
|
||||
|
|
@ -448,24 +422,43 @@ async fn test_batch_get_completed_by_drv_paths() {
|
|||
let drv2 = format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
let drv_missing = format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
|
||||
let b1 = create_test_build(&pool, eval.id, "pkg1", &drv1, Some("x86_64-linux")).await;
|
||||
let b2 = create_test_build(&pool, eval.id, "pkg2", &drv2, Some("x86_64-linux")).await;
|
||||
let b1 =
|
||||
create_test_build(&pool, eval.id, "pkg1", &drv1, Some("x86_64-linux"))
|
||||
.await;
|
||||
let b2 =
|
||||
create_test_build(&pool, eval.id, "pkg2", &drv2, Some("x86_64-linux"))
|
||||
.await;
|
||||
|
||||
// Start and complete both
|
||||
repo::builds::start(&pool, b1.id).await.unwrap();
|
||||
repo::builds::complete(&pool, b1.id, BuildStatus::Completed, None, None, None)
|
||||
repo::builds::complete(
|
||||
&pool,
|
||||
b1.id,
|
||||
BuildStatus::Completed,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo::builds::start(&pool, b2.id).await.unwrap();
|
||||
repo::builds::complete(&pool, b2.id, BuildStatus::Completed, None, None, None)
|
||||
repo::builds::complete(
|
||||
&pool,
|
||||
b2.id,
|
||||
BuildStatus::Completed,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Batch query
|
||||
let results = repo::builds::get_completed_by_drv_paths(
|
||||
&pool,
|
||||
&[drv1.clone(), drv2.clone(), drv_missing.clone()],
|
||||
)
|
||||
let results = repo::builds::get_completed_by_drv_paths(&pool, &[
|
||||
drv1.clone(),
|
||||
drv2.clone(),
|
||||
drv_missing.clone(),
|
||||
])
|
||||
.await
|
||||
.expect("batch get");
|
||||
|
||||
|
|
@ -498,11 +491,16 @@ async fn test_batch_check_deps_for_builds() {
|
|||
// Create dep (will be completed) and dependent (pending)
|
||||
let dep_drv = format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
let main_drv = format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
let standalone_drv = format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
let standalone_drv =
|
||||
format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
|
||||
let dep_build = create_test_build(&pool, eval.id, "dep", &dep_drv, None).await;
|
||||
let main_build = create_test_build(&pool, eval.id, "main", &main_drv, None).await;
|
||||
let standalone = create_test_build(&pool, eval.id, "standalone", &standalone_drv, None).await;
|
||||
let dep_build =
|
||||
create_test_build(&pool, eval.id, "dep", &dep_drv, None).await;
|
||||
let main_build =
|
||||
create_test_build(&pool, eval.id, "main", &main_drv, None).await;
|
||||
let standalone =
|
||||
create_test_build(&pool, eval.id, "standalone", &standalone_drv, None)
|
||||
.await;
|
||||
|
||||
// Create dependency: main depends on dep
|
||||
repo::build_dependencies::create(&pool, main_build.id, dep_build.id)
|
||||
|
|
@ -510,8 +508,10 @@ async fn test_batch_check_deps_for_builds() {
|
|||
.expect("create dep");
|
||||
|
||||
// Before dep is completed, main should have incomplete deps
|
||||
let results =
|
||||
repo::build_dependencies::check_deps_for_builds(&pool, &[main_build.id, standalone.id])
|
||||
let results = repo::build_dependencies::check_deps_for_builds(&pool, &[
|
||||
main_build.id,
|
||||
standalone.id,
|
||||
])
|
||||
.await
|
||||
.expect("batch check deps");
|
||||
|
||||
|
|
@ -532,8 +532,10 @@ async fn test_batch_check_deps_for_builds() {
|
|||
.unwrap();
|
||||
|
||||
// Recheck
|
||||
let results =
|
||||
repo::build_dependencies::check_deps_for_builds(&pool, &[main_build.id, standalone.id])
|
||||
let results = repo::build_dependencies::check_deps_for_builds(&pool, &[
|
||||
main_build.id,
|
||||
standalone.id,
|
||||
])
|
||||
.await
|
||||
.expect("batch check deps after complete");
|
||||
|
||||
|
|
@ -564,8 +566,10 @@ async fn test_list_filtered_with_system_filter() {
|
|||
let drv_x86 = format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
let drv_arm = format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
|
||||
create_test_build(&pool, eval.id, "x86-pkg", &drv_x86, Some("x86_64-linux")).await;
|
||||
create_test_build(&pool, eval.id, "arm-pkg", &drv_arm, Some("aarch64-linux")).await;
|
||||
create_test_build(&pool, eval.id, "x86-pkg", &drv_x86, Some("x86_64-linux"))
|
||||
.await;
|
||||
create_test_build(&pool, eval.id, "arm-pkg", &drv_arm, Some("aarch64-linux"))
|
||||
.await;
|
||||
|
||||
// Filter by x86_64-linux
|
||||
let x86_builds = repo::builds::list_filtered(
|
||||
|
|
@ -606,8 +610,13 @@ async fn test_list_filtered_with_system_filter() {
|
|||
assert!(!arm_builds.is_empty());
|
||||
|
||||
// Count
|
||||
let x86_count =
|
||||
repo::builds::count_filtered(&pool, Some(eval.id), None, Some("x86_64-linux"), None)
|
||||
let x86_count = repo::builds::count_filtered(
|
||||
&pool,
|
||||
Some(eval.id),
|
||||
None,
|
||||
Some("x86_64-linux"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("count x86");
|
||||
assert_eq!(x86_count, x86_builds.len() as i64);
|
||||
|
|
@ -636,22 +645,42 @@ async fn test_list_filtered_with_job_name_filter() {
|
|||
create_test_build(&pool, eval.id, "goodbye", &drv3, None).await;
|
||||
|
||||
// ILIKE filter should match both hello-world and hello-lib
|
||||
let hello_builds =
|
||||
repo::builds::list_filtered(&pool, Some(eval.id), None, None, Some("hello"), 50, 0)
|
||||
let hello_builds = repo::builds::list_filtered(
|
||||
&pool,
|
||||
Some(eval.id),
|
||||
None,
|
||||
None,
|
||||
Some("hello"),
|
||||
50,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.expect("filter hello");
|
||||
assert_eq!(hello_builds.len(), 2);
|
||||
assert!(hello_builds.iter().all(|b| b.job_name.contains("hello")));
|
||||
|
||||
// "goodbye" should only match one
|
||||
let goodbye_builds =
|
||||
repo::builds::list_filtered(&pool, Some(eval.id), None, None, Some("goodbye"), 50, 0)
|
||||
let goodbye_builds = repo::builds::list_filtered(
|
||||
&pool,
|
||||
Some(eval.id),
|
||||
None,
|
||||
None,
|
||||
Some("goodbye"),
|
||||
50,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.expect("filter goodbye");
|
||||
assert_eq!(goodbye_builds.len(), 1);
|
||||
|
||||
// Count matches
|
||||
let count = repo::builds::count_filtered(&pool, Some(eval.id), None, None, Some("hello"))
|
||||
let count = repo::builds::count_filtered(
|
||||
&pool,
|
||||
Some(eval.id),
|
||||
None,
|
||||
None,
|
||||
Some("hello"),
|
||||
)
|
||||
.await
|
||||
.expect("count hello");
|
||||
assert_eq!(count, 2);
|
||||
|
|
@ -671,13 +700,17 @@ async fn test_reset_orphaned_batch_limit() {
|
|||
let jobset = create_test_jobset(&pool, project.id).await;
|
||||
let eval = create_test_eval(&pool, jobset.id).await;
|
||||
|
||||
// Create and start a build, then set started_at far in the past to simulate orphan
|
||||
// Create and start a build, then set started_at far in the past to simulate
|
||||
// orphan
|
||||
let drv = format!("/nix/store/{}.drv", uuid::Uuid::new_v4().simple());
|
||||
let build = create_test_build(&pool, eval.id, "orphan-test", &drv, None).await;
|
||||
let build =
|
||||
create_test_build(&pool, eval.id, "orphan-test", &drv, None).await;
|
||||
repo::builds::start(&pool, build.id).await.unwrap();
|
||||
|
||||
// Set started_at to 2 hours ago to make it look orphaned
|
||||
sqlx::query("UPDATE builds SET started_at = NOW() - INTERVAL '2 hours' WHERE id = $1")
|
||||
sqlx::query(
|
||||
"UPDATE builds SET started_at = NOW() - INTERVAL '2 hours' WHERE id = $1",
|
||||
)
|
||||
.bind(build.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
|
|
@ -754,7 +787,14 @@ async fn test_dedup_by_drv_path() {
|
|||
|
||||
// Complete it
|
||||
repo::builds::start(&pool, build.id).await.unwrap();
|
||||
repo::builds::complete(&pool, build.id, BuildStatus::Completed, None, None, None)
|
||||
repo::builds::complete(
|
||||
&pool,
|
||||
build.id,
|
||||
BuildStatus::Completed,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,23 +7,23 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
git2.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
futures.workspace = true
|
||||
toml.workspace = true
|
||||
sha2.workspace = true
|
||||
git2.workspace = true
|
||||
hex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
# Our crates
|
||||
fc-common.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use fc_common::{
|
||||
config::EvaluatorConfig,
|
||||
models::{CreateBuild, CreateEvaluation, EvaluationStatus, JobsetInput},
|
||||
repo,
|
||||
};
|
||||
use futures::stream::{self, StreamExt};
|
||||
|
||||
use fc_common::config::EvaluatorConfig;
|
||||
use fc_common::models::{CreateBuild, CreateEvaluation, EvaluationStatus, JobsetInput};
|
||||
use fc_common::repo;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -34,14 +34,18 @@ async fn run_cycle(
|
|||
let max_concurrent = config.max_concurrent_evals;
|
||||
|
||||
stream::iter(active)
|
||||
.for_each_concurrent(max_concurrent, |jobset| async move {
|
||||
if let Err(e) = evaluate_jobset(pool, &jobset, config, nix_timeout, git_timeout).await {
|
||||
.for_each_concurrent(max_concurrent, |jobset| {
|
||||
async move {
|
||||
if let Err(e) =
|
||||
evaluate_jobset(pool, &jobset, config, nix_timeout, git_timeout).await
|
||||
{
|
||||
tracing::error!(
|
||||
jobset_id = %jobset.id,
|
||||
jobset_name = %jobset.name,
|
||||
"Failed to evaluate jobset: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
|
|
@ -64,11 +68,18 @@ async fn evaluate_jobset(
|
|||
let (repo_path, commit_hash) = tokio::time::timeout(
|
||||
git_timeout,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::git::clone_or_fetch(&url, &work_dir, &project_name, branch.as_deref())
|
||||
crate::git::clone_or_fetch(
|
||||
&url,
|
||||
&work_dir,
|
||||
&project_name,
|
||||
branch.as_deref(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Git operation timed out after {git_timeout:?}"))???;
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("Git operation timed out after {git_timeout:?}")
|
||||
})???;
|
||||
|
||||
// Query jobset inputs
|
||||
let inputs = repo::jobset_inputs::list_for_jobset(pool, jobset.id)
|
||||
|
|
@ -94,7 +105,8 @@ async fn evaluate_jobset(
|
|||
|
||||
// Also skip if commit hasn't changed (backward compat)
|
||||
if let Some(latest) = repo::evaluations::get_latest(pool, jobset.id).await?
|
||||
&& latest.commit_hash == commit_hash && latest.inputs_hash.as_deref() == Some(&inputs_hash)
|
||||
&& latest.commit_hash == commit_hash
|
||||
&& latest.inputs_hash.as_deref() == Some(&inputs_hash)
|
||||
{
|
||||
tracing::debug!(
|
||||
jobset = %jobset.name,
|
||||
|
|
@ -111,17 +123,20 @@ async fn evaluate_jobset(
|
|||
);
|
||||
|
||||
// Create evaluation record
|
||||
let eval = repo::evaluations::create(
|
||||
pool,
|
||||
CreateEvaluation {
|
||||
let eval = repo::evaluations::create(pool, CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: commit_hash.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Mark as running and set inputs hash
|
||||
repo::evaluations::update_status(pool, eval.id, EvaluationStatus::Running, None).await?;
|
||||
repo::evaluations::update_status(
|
||||
pool,
|
||||
eval.id,
|
||||
EvaluationStatus::Running,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let _ = repo::evaluations::set_inputs_hash(pool, eval.id, &inputs_hash).await;
|
||||
|
||||
// Check for declarative config in repo
|
||||
|
|
@ -146,7 +161,8 @@ async fn evaluate_jobset(
|
|||
"Evaluation discovered jobs"
|
||||
);
|
||||
|
||||
// Create build records, tracking drv_path -> build_id for dependency resolution
|
||||
// Create build records, tracking drv_path -> build_id for dependency
|
||||
// resolution
|
||||
let mut drv_to_build: HashMap<String, Uuid> = HashMap::new();
|
||||
let mut name_to_build: HashMap<String, Uuid> = HashMap::new();
|
||||
|
||||
|
|
@ -161,9 +177,7 @@ async fn evaluate_jobset(
|
|||
.map(|c| serde_json::to_value(c).unwrap_or_default());
|
||||
let is_aggregate = job.constituents.is_some();
|
||||
|
||||
let build = repo::builds::create(
|
||||
pool,
|
||||
CreateBuild {
|
||||
let build = repo::builds::create(pool, CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: job.name.clone(),
|
||||
drv_path: job.drv_path.clone(),
|
||||
|
|
@ -171,8 +185,7 @@ async fn evaluate_jobset(
|
|||
outputs: outputs_json,
|
||||
is_aggregate: Some(is_aggregate),
|
||||
constituents: constituents_json,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
drv_to_build.insert(job.drv_path.clone(), build.id);
|
||||
|
|
@ -190,7 +203,8 @@ async fn evaluate_jobset(
|
|||
if let Some(ref input_drvs) = job.input_drvs {
|
||||
for dep_drv in input_drvs.keys() {
|
||||
if let Some(&dep_build_id) = drv_to_build.get(dep_drv)
|
||||
&& dep_build_id != build_id {
|
||||
&& dep_build_id != build_id
|
||||
{
|
||||
let _ =
|
||||
repo::build_dependencies::create(pool, build_id, dep_build_id)
|
||||
.await;
|
||||
|
|
@ -202,7 +216,8 @@ async fn evaluate_jobset(
|
|||
if let Some(ref constituents) = job.constituents {
|
||||
for constituent_name in constituents {
|
||||
if let Some(&dep_build_id) = name_to_build.get(constituent_name)
|
||||
&& dep_build_id != build_id {
|
||||
&& dep_build_id != build_id
|
||||
{
|
||||
let _ =
|
||||
repo::build_dependencies::create(pool, build_id, dep_build_id)
|
||||
.await;
|
||||
|
|
@ -211,15 +226,25 @@ async fn evaluate_jobset(
|
|||
}
|
||||
}
|
||||
|
||||
repo::evaluations::update_status(pool, eval.id, EvaluationStatus::Completed, None)
|
||||
repo::evaluations::update_status(
|
||||
pool,
|
||||
eval.id,
|
||||
EvaluationStatus::Completed,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
tracing::error!(jobset = %jobset.name, "Evaluation failed: {msg}");
|
||||
repo::evaluations::update_status(pool, eval.id, EvaluationStatus::Failed, Some(&msg))
|
||||
repo::evaluations::update_status(
|
||||
pool,
|
||||
eval.id,
|
||||
EvaluationStatus::Failed,
|
||||
Some(&msg),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -249,8 +274,13 @@ fn compute_inputs_hash(commit_hash: &str, inputs: &[JobsetInput]) -> String {
|
|||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Check for declarative project config (.fc.toml or .fc/config.toml) in the repo.
|
||||
async fn check_declarative_config(pool: &PgPool, repo_path: &std::path::Path, project_id: Uuid) {
|
||||
/// Check for declarative project config (.fc.toml or .fc/config.toml) in the
|
||||
/// repo.
|
||||
async fn check_declarative_config(
|
||||
pool: &PgPool,
|
||||
repo_path: &std::path::Path,
|
||||
project_id: Uuid,
|
||||
) {
|
||||
let config_path = repo_path.join(".fc.toml");
|
||||
let alt_config_path = repo_path.join(".fc/config.toml");
|
||||
|
||||
|
|
@ -265,9 +295,12 @@ async fn check_declarative_config(pool: &PgPool, repo_path: &std::path::Path, pr
|
|||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read declarative config {}: {e}", path.display());
|
||||
tracing::warn!(
|
||||
"Failed to read declarative config {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
|
|
@ -289,7 +322,7 @@ async fn check_declarative_config(pool: &PgPool, repo_path: &std::path::Path, pr
|
|||
Err(e) => {
|
||||
tracing::warn!("Failed to parse declarative config: {e}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(jobsets) = config.jobsets {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use git2::Repository;
|
|||
|
||||
/// Clone or fetch a repository. Returns (repo_path, commit_hash).
|
||||
///
|
||||
/// If `branch` is `Some`, resolve `refs/remotes/origin/<branch>` instead of HEAD.
|
||||
/// If `branch` is `Some`, resolve `refs/remotes/origin/<branch>` instead of
|
||||
/// HEAD.
|
||||
#[tracing::instrument(skip(work_dir))]
|
||||
pub fn clone_or_fetch(
|
||||
url: &str,
|
||||
|
|
@ -17,7 +18,8 @@ pub fn clone_or_fetch(
|
|||
|
||||
let repo = if repo_path.exists() {
|
||||
let repo = Repository::open(&repo_path)?;
|
||||
// Fetch origin — scope the borrow so `remote` is dropped before we move `repo`
|
||||
// Fetch origin — scope the borrow so `remote` is dropped before we move
|
||||
// `repo`
|
||||
{
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
remote.fetch(&["refs/heads/*:refs/remotes/origin/*"], None, None)?;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashMap, path::Path, time::Duration};
|
||||
|
||||
use fc_common::CiError;
|
||||
use fc_common::config::EvaluatorConfig;
|
||||
use fc_common::error::Result;
|
||||
use fc_common::models::JobsetInput;
|
||||
use fc_common::{
|
||||
CiError,
|
||||
config::EvaluatorConfig,
|
||||
error::Result,
|
||||
models::JobsetInput,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -46,7 +46,8 @@ pub fn parse_eval_output(stdout: &str) -> EvalResult {
|
|||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(line)
|
||||
&& parsed.get("error").is_some() {
|
||||
&& parsed.get("error").is_some()
|
||||
{
|
||||
if let Ok(eval_err) = serde_json::from_str::<NixEvalError>(line) {
|
||||
let name = eval_err.name.as_deref().unwrap_or("<unknown>");
|
||||
tracing::warn!(
|
||||
|
|
@ -63,7 +64,7 @@ pub fn parse_eval_output(stdout: &str) -> EvalResult {
|
|||
Ok(job) => jobs.push(job),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse nix-eval-jobs line: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,8 +100,6 @@ async fn evaluate_flake(
|
|||
) -> Result<EvalResult> {
|
||||
let flake_ref = format!("{}#{}", repo_path.display(), nix_expression);
|
||||
|
||||
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let mut cmd = tokio::process::Command::new("nix-eval-jobs");
|
||||
cmd.arg("--flake").arg(&flake_ref);
|
||||
|
|
@ -132,7 +131,7 @@ async fn evaluate_flake(
|
|||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
tracing::info!("nix-eval-jobs unavailable, falling back to nix eval");
|
||||
let jobs = evaluate_with_nix_eval(repo_path, nix_expression).await?;
|
||||
|
|
@ -140,14 +139,17 @@ async fn evaluate_flake(
|
|||
jobs,
|
||||
error_count: 0,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CiError::Timeout(format!("Nix evaluation timed out after {timeout:?}")))?
|
||||
.map_err(|_| {
|
||||
CiError::Timeout(format!("Nix evaluation timed out after {timeout:?}"))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Legacy (non-flake) evaluation: import the nix expression file and evaluate it.
|
||||
/// Legacy (non-flake) evaluation: import the nix expression file and evaluate
|
||||
/// it.
|
||||
#[tracing::instrument(skip(config, inputs))]
|
||||
async fn evaluate_legacy(
|
||||
repo_path: &Path,
|
||||
|
|
@ -158,8 +160,6 @@ async fn evaluate_legacy(
|
|||
) -> Result<EvalResult> {
|
||||
let expr_path = repo_path.join(nix_expression);
|
||||
|
||||
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
// Try nix-eval-jobs without --flake for legacy expressions
|
||||
let mut cmd = tokio::process::Command::new("nix-eval-jobs");
|
||||
|
|
@ -183,17 +183,21 @@ async fn evaluate_legacy(
|
|||
Ok(out) if out.status.success() || !out.stdout.is_empty() => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
Ok(parse_eval_output(&stdout))
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// Fallback: nix eval on the legacy import
|
||||
tracing::info!("nix-eval-jobs unavailable for legacy expr, using nix-instantiate");
|
||||
tracing::info!(
|
||||
"nix-eval-jobs unavailable for legacy expr, using nix-instantiate"
|
||||
);
|
||||
let output = tokio::process::Command::new("nix-instantiate")
|
||||
.arg(&expr_path)
|
||||
.arg("--strict")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| CiError::NixEval(format!("nix-instantiate failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
CiError::NixEval(format!("nix-instantiate failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
|
@ -204,17 +208,20 @@ async fn evaluate_legacy(
|
|||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// nix-instantiate --json outputs the derivation path(s)
|
||||
let drv_paths: Vec<String> = serde_json::from_str(&stdout).unwrap_or_default();
|
||||
let drv_paths: Vec<String> =
|
||||
serde_json::from_str(&stdout).unwrap_or_default();
|
||||
let jobs: Vec<NixJob> = drv_paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, drv_path)| NixJob {
|
||||
.map(|(i, drv_path)| {
|
||||
NixJob {
|
||||
name: format!("job-{i}"),
|
||||
drv_path,
|
||||
system: None,
|
||||
outputs: None,
|
||||
input_drvs: None,
|
||||
constituents: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -222,14 +229,19 @@ async fn evaluate_legacy(
|
|||
jobs,
|
||||
error_count: 0,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CiError::Timeout(format!("Nix evaluation timed out after {timeout:?}")))?
|
||||
.map_err(|_| {
|
||||
CiError::Timeout(format!("Nix evaluation timed out after {timeout:?}"))
|
||||
})?
|
||||
}
|
||||
|
||||
async fn evaluate_with_nix_eval(repo_path: &Path, nix_expression: &str) -> Result<Vec<NixJob>> {
|
||||
async fn evaluate_with_nix_eval(
|
||||
repo_path: &Path,
|
||||
nix_expression: &str,
|
||||
) -> Result<Vec<NixJob>> {
|
||||
let flake_ref = format!("{}#{}", repo_path.display(), nix_expression);
|
||||
|
||||
let output = tokio::process::Command::new("nix")
|
||||
|
|
@ -245,14 +257,17 @@ async fn evaluate_with_nix_eval(repo_path: &Path, nix_expression: &str) -> Resul
|
|||
|
||||
// Parse the JSON output - expecting an attrset of name -> derivation
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let attrs: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.map_err(|e| CiError::NixEval(format!("Failed to parse nix eval output: {e}")))?;
|
||||
let attrs: serde_json::Value =
|
||||
serde_json::from_str(&stdout).map_err(|e| {
|
||||
CiError::NixEval(format!("Failed to parse nix eval output: {e}"))
|
||||
})?;
|
||||
|
||||
let mut jobs = Vec::new();
|
||||
if let serde_json::Value::Object(map) = attrs {
|
||||
for (name, _value) in map {
|
||||
// Get derivation path via nix derivation show
|
||||
let drv_ref = format!("{}#{}.{}", repo_path.display(), nix_expression, name);
|
||||
let drv_ref =
|
||||
format!("{}#{}.{}", repo_path.display(), nix_expression, name);
|
||||
let drv_output = tokio::process::Command::new("nix")
|
||||
.args(["derivation", "show", &drv_ref])
|
||||
.output()
|
||||
|
|
@ -263,7 +278,8 @@ async fn evaluate_with_nix_eval(repo_path: &Path, nix_expression: &str) -> Resul
|
|||
|
||||
if drv_output.status.success() {
|
||||
let drv_stdout = String::from_utf8_lossy(&drv_output.stdout);
|
||||
if let Ok(drv_json) = serde_json::from_str::<serde_json::Value>(&drv_stdout)
|
||||
if let Ok(drv_json) =
|
||||
serde_json::from_str::<serde_json::Value>(&drv_stdout)
|
||||
&& let Some((drv_path, drv_val)) =
|
||||
drv_json.as_object().and_then(|o| o.iter().next())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ fn test_parse_blank_lines_ignored() {
|
|||
|
||||
#[test]
|
||||
fn test_parse_malformed_json_skipped() {
|
||||
let output =
|
||||
"not json at all\n{invalid json}\n{\"name\":\"ok\",\"drvPath\":\"/nix/store/x-ok.drv\"}";
|
||||
let output = "not json at all\n{invalid \
|
||||
json}\n{\"name\":\"ok\",\"drvPath\":\"/nix/store/x-ok.drv\"}";
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
assert_eq!(result.jobs[0].name, "ok");
|
||||
|
|
@ -91,7 +91,7 @@ fn test_parse_error_without_name() {
|
|||
|
||||
#[test]
|
||||
fn test_inputs_hash_deterministic() {
|
||||
// The compute_inputs_hash function is in eval_loop which is not easily testable
|
||||
// as a standalone function since it's not public. We test the nix parsing above
|
||||
// and trust the hash logic is correct since it uses sha2.
|
||||
// The compute_inputs_hash function is in eval_loop which is not easily
|
||||
// testable as a standalone function since it's not public. We test the nix
|
||||
// parsing above and trust the hash logic is correct since it uses sha2.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ fn test_clone_or_fetch_clones_new_repo() {
|
|||
}
|
||||
|
||||
let url = format!("file://{}", upstream_dir.path().display());
|
||||
let result = fc_evaluator::git::clone_or_fetch(&url, work_dir.path(), "test-project", None);
|
||||
let result = fc_evaluator::git::clone_or_fetch(
|
||||
&url,
|
||||
work_dir.path(),
|
||||
"test-project",
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
|
|
@ -54,7 +59,12 @@ fn test_clone_or_fetch_fetches_existing() {
|
|||
|
||||
// First clone
|
||||
let (_, hash1): (std::path::PathBuf, String) =
|
||||
fc_evaluator::git::clone_or_fetch(&url, work_dir.path(), "test-project", None)
|
||||
fc_evaluator::git::clone_or_fetch(
|
||||
&url,
|
||||
work_dir.path(),
|
||||
"test-project",
|
||||
None,
|
||||
)
|
||||
.expect("first clone failed");
|
||||
|
||||
// Make another commit upstream
|
||||
|
|
@ -70,7 +80,12 @@ fn test_clone_or_fetch_fetches_existing() {
|
|||
|
||||
// Second fetch
|
||||
let (_, hash2): (std::path::PathBuf, String) =
|
||||
fc_evaluator::git::clone_or_fetch(&url, work_dir.path(), "test-project", None)
|
||||
fc_evaluator::git::clone_or_fetch(
|
||||
&url,
|
||||
work_dir.path(),
|
||||
"test-project",
|
||||
None,
|
||||
)
|
||||
.expect("second fetch failed");
|
||||
|
||||
assert!(!hash1.is_empty());
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ name = "fc-migrate"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
fc-common = { path = "../common" }
|
||||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
fc-common = { path = "../common" }
|
||||
tokio.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -7,19 +7,19 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
# Our crates
|
||||
fc-common.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use fc_common::CiError;
|
||||
use fc_common::error::Result;
|
||||
use fc_common::{CiError, error::Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
const MAX_LOG_SIZE: usize = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
/// Run a build on a remote machine via `nix build --store ssh://...`.
|
||||
#[tracing::instrument(skip(work_dir, live_log_path), fields(drv_path, store_uri))]
|
||||
#[tracing::instrument(
|
||||
skip(work_dir, live_log_path),
|
||||
fields(drv_path, store_uri)
|
||||
)]
|
||||
pub async fn run_nix_build_remote(
|
||||
drv_path: &str,
|
||||
work_dir: &Path,
|
||||
|
|
@ -19,7 +20,8 @@ pub async fn run_nix_build_remote(
|
|||
) -> Result<BuildResult> {
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
let mut cmd = tokio::process::Command::new("nix");
|
||||
cmd.args([
|
||||
cmd
|
||||
.args([
|
||||
"build",
|
||||
"--no-link",
|
||||
"--print-out-paths",
|
||||
|
|
@ -46,9 +48,9 @@ pub async fn run_nix_build_remote(
|
|||
);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| CiError::Build(format!("Failed to run remote nix build: {e}")))?;
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
CiError::Build(format!("Failed to run remote nix build: {e}"))
|
||||
})?;
|
||||
|
||||
let stdout_handle = child.stdout.take();
|
||||
let stderr_handle = child.stderr.take();
|
||||
|
|
@ -97,10 +99,9 @@ pub async fn run_nix_build_remote(
|
|||
let stdout_buf = stdout_task.await.unwrap_or_default();
|
||||
let (stderr_buf, sub_steps) = stderr_task.await.unwrap_or_default();
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| CiError::Build(format!("Failed to wait for remote nix build: {e}")))?;
|
||||
let status = child.wait().await.map_err(|e| {
|
||||
CiError::Build(format!("Failed to wait for remote nix build: {e}"))
|
||||
})?;
|
||||
|
||||
let output_paths: Vec<String> = stdout_buf
|
||||
.lines()
|
||||
|
|
@ -120,9 +121,11 @@ pub async fn run_nix_build_remote(
|
|||
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(CiError::Timeout(format!(
|
||||
Err(_) => {
|
||||
Err(CiError::Timeout(format!(
|
||||
"Remote build timed out after {timeout:?}"
|
||||
))),
|
||||
)))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +160,8 @@ 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.
|
||||
/// If `live_log_path` is provided, build output is streamed to that file
|
||||
/// incrementally.
|
||||
#[tracing::instrument(skip(work_dir, live_log_path), fields(drv_path))]
|
||||
pub async fn run_nix_build(
|
||||
drv_path: &str,
|
||||
|
|
@ -244,7 +248,7 @@ pub async fn run_nix_build(
|
|||
success: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
"stop" => {
|
||||
if let Some(drv) =
|
||||
parsed.get("derivation").and_then(|d| d.as_str())
|
||||
|
|
@ -254,8 +258,8 @@ pub async fn run_nix_build(
|
|||
step.completed_at = Some(chrono::Utc::now());
|
||||
step.success = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,10 +275,9 @@ pub async fn run_nix_build(
|
|||
let stdout_buf = stdout_task.await.unwrap_or_default();
|
||||
let (stderr_buf, sub_steps) = stderr_task.await.unwrap_or_default();
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| CiError::Build(format!("Failed to wait for nix build: {e}")))?;
|
||||
let status = child.wait().await.map_err(|e| {
|
||||
CiError::Build(format!("Failed to wait for nix build: {e}"))
|
||||
})?;
|
||||
|
||||
let output_paths: Vec<String> = stdout_buf
|
||||
.lines()
|
||||
|
|
@ -294,8 +297,10 @@ pub async fn run_nix_build(
|
|||
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(CiError::Timeout(format!(
|
||||
Err(_) => {
|
||||
Err(CiError::Timeout(format!(
|
||||
"Build timed out after {timeout:?}"
|
||||
))),
|
||||
)))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
use std::time::Duration;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use fc_common::config::{Config, GcConfig};
|
||||
use fc_common::database::Database;
|
||||
use fc_common::gc_roots;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fc_common::{
|
||||
config::{Config, GcConfig},
|
||||
database::Database,
|
||||
gc_roots,
|
||||
};
|
||||
use fc_queue_runner::worker::WorkerPool;
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -119,20 +118,20 @@ async fn gc_loop(gc_config: GcConfig) {
|
|||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
tracing::info!("nix-collect-garbage completed");
|
||||
}
|
||||
},
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::warn!("nix-collect-garbage failed: {stderr}");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to run nix-collect-garbage: {e}");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
},
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
tracing::error!("GC cleanup failed: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use fc_common::{models::BuildStatus, repo};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use fc_common::models::BuildStatus;
|
||||
use fc_common::repo;
|
||||
|
||||
use crate::worker::WorkerPool;
|
||||
|
||||
pub async fn run(
|
||||
|
|
@ -17,11 +14,11 @@ pub async fn run(
|
|||
match repo::builds::reset_orphaned(&pool, 300).await {
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::warn!(count, "Reset orphaned builds back to pending");
|
||||
}
|
||||
Ok(_) => {}
|
||||
},
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to reset orphaned builds: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
loop {
|
||||
|
|
@ -33,7 +30,9 @@ pub async fn run(
|
|||
for build in builds {
|
||||
// Aggregate builds: check if all constituents are done
|
||||
if build.is_aggregate {
|
||||
match repo::build_dependencies::all_deps_completed(&pool, build.id).await {
|
||||
match repo::build_dependencies::all_deps_completed(&pool, build.id)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
// All constituents done — mark aggregate as completed
|
||||
tracing::info!(
|
||||
|
|
@ -52,26 +51,29 @@ pub async fn run(
|
|||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
build_id = %build.id,
|
||||
"Aggregate build waiting for constituents"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
build_id = %build.id,
|
||||
"Failed to check aggregate deps: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Derivation deduplication: reuse result if same drv was already built
|
||||
match repo::builds::get_completed_by_drv_path(&pool, &build.drv_path).await {
|
||||
// Derivation deduplication: reuse result if same drv was already
|
||||
// built
|
||||
match repo::builds::get_completed_by_drv_path(&pool, &build.drv_path)
|
||||
.await
|
||||
{
|
||||
Ok(Some(existing)) if existing.id != build.id => {
|
||||
tracing::info!(
|
||||
build_id = %build.id,
|
||||
|
|
@ -90,35 +92,37 @@ pub async fn run(
|
|||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
|
||||
// Dependency-aware scheduling: skip if deps not met
|
||||
match repo::build_dependencies::all_deps_completed(&pool, build.id).await {
|
||||
Ok(true) => {}
|
||||
match repo::build_dependencies::all_deps_completed(&pool, build.id)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {},
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
build_id = %build.id,
|
||||
"Build waiting for dependencies"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
build_id = %build.id,
|
||||
"Failed to check build deps: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
worker_pool.dispatch(build);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch pending builds: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use fc_common::{
|
||||
config::{
|
||||
CacheUploadConfig,
|
||||
GcConfig,
|
||||
LogConfig,
|
||||
NotificationsConfig,
|
||||
SigningConfig,
|
||||
},
|
||||
gc_roots::GcRoots,
|
||||
log_storage::LogStorage,
|
||||
models::{Build, BuildStatus, CreateBuildProduct, CreateBuildStep},
|
||||
repo,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use fc_common::config::{
|
||||
CacheUploadConfig, GcConfig, LogConfig, NotificationsConfig, SigningConfig,
|
||||
};
|
||||
use fc_common::gc_roots::GcRoots;
|
||||
use fc_common::log_storage::LogStorage;
|
||||
use fc_common::models::{Build, BuildStatus, CreateBuildProduct, CreateBuildStep};
|
||||
use fc_common::repo;
|
||||
|
||||
pub struct WorkerPool {
|
||||
semaphore: Arc<Semaphore>,
|
||||
pool: PgPool,
|
||||
|
|
@ -52,7 +55,8 @@ impl WorkerPool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Signal all workers to stop accepting new builds. In-flight builds will finish.
|
||||
/// Signal all workers to stop accepting new builds. In-flight builds will
|
||||
/// finish.
|
||||
pub fn drain(&self) {
|
||||
self.drain_token.cancel();
|
||||
}
|
||||
|
|
@ -138,7 +142,8 @@ async fn get_path_info(output_path: &str) -> Option<(String, i64)> {
|
|||
Some((nar_hash, nar_size))
|
||||
}
|
||||
|
||||
/// Look up the project that owns a build (build -> evaluation -> jobset -> project).
|
||||
/// Look up the project that owns a build (build -> evaluation -> jobset ->
|
||||
/// project).
|
||||
async fn get_project_for_build(
|
||||
pool: &PgPool,
|
||||
build: &Build,
|
||||
|
|
@ -152,7 +157,10 @@ async fn get_project_for_build(
|
|||
}
|
||||
|
||||
/// Sign nix store outputs using the configured signing key.
|
||||
async fn sign_outputs(output_paths: &[String], signing_config: &SigningConfig) -> bool {
|
||||
async fn sign_outputs(
|
||||
output_paths: &[String],
|
||||
signing_config: &SigningConfig,
|
||||
) -> bool {
|
||||
let key_file = match &signing_config.key_file {
|
||||
Some(kf) if signing_config.enabled && kf.exists() => kf,
|
||||
_ => return false,
|
||||
|
|
@ -173,14 +181,17 @@ async fn sign_outputs(output_paths: &[String], signing_config: &SigningConfig) -
|
|||
match result {
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::debug!(output = output_path, "Signed store path");
|
||||
}
|
||||
},
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
tracing::warn!(output = output_path, "Failed to sign: {stderr}");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(output = output_path, "Failed to run nix store sign: {e}");
|
||||
}
|
||||
tracing::warn!(
|
||||
output = output_path,
|
||||
"Failed to run nix store sign: {e}"
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
true
|
||||
|
|
@ -195,20 +206,25 @@ async fn push_to_cache(output_paths: &[String], store_uri: &str) {
|
|||
.await;
|
||||
match result {
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::debug!(output = path, store = store_uri, "Pushed to binary cache");
|
||||
}
|
||||
tracing::debug!(
|
||||
output = path,
|
||||
store = store_uri,
|
||||
"Pushed to binary cache"
|
||||
);
|
||||
},
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
tracing::warn!(output = path, "Failed to push to cache: {stderr}");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(output = path, "Failed to run nix copy: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to run the build on a remote builder if one is available for the build's system.
|
||||
/// Try to run the build on a remote builder if one is available for the build's
|
||||
/// system.
|
||||
async fn try_remote_build(
|
||||
pool: &PgPool,
|
||||
build: &Build,
|
||||
|
|
@ -253,7 +269,7 @@ async fn try_remote_build(
|
|||
builder = %builder.name,
|
||||
"Remote build failed: {e}, trying next builder"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,23 +298,26 @@ async fn run_build(
|
|||
tracing::info!(build_id = %build.id, job = %build.job_name, "Starting build");
|
||||
|
||||
// Create a build step record
|
||||
let step = repo::build_steps::create(
|
||||
pool,
|
||||
CreateBuildStep {
|
||||
let step = repo::build_steps::create(pool, CreateBuildStep {
|
||||
build_id: build.id,
|
||||
step_number: 1,
|
||||
command: format!("nix build --no-link --print-out-paths {}", build.drv_path),
|
||||
},
|
||||
)
|
||||
command: format!(
|
||||
"nix build --no-link --print-out-paths {}",
|
||||
build.drv_path
|
||||
),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Set up live log path
|
||||
let live_log_path = log_config.log_dir.join(format!("{}.active.log", build.id));
|
||||
let live_log_path =
|
||||
log_config.log_dir.join(format!("{}.active.log", build.id));
|
||||
let _ = tokio::fs::create_dir_all(&log_config.log_dir).await;
|
||||
|
||||
// Try remote build first, then fall back to local
|
||||
let result = if build.system.is_some() {
|
||||
match try_remote_build(pool, build, work_dir, timeout, Some(&live_log_path)).await {
|
||||
match try_remote_build(pool, build, work_dir, timeout, Some(&live_log_path))
|
||||
.await
|
||||
{
|
||||
Some(r) => Ok(r),
|
||||
None => {
|
||||
// No remote builder available or all failed — build locally
|
||||
|
|
@ -309,10 +328,15 @@ async fn run_build(
|
|||
Some(&live_log_path),
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
crate::builder::run_nix_build(&build.drv_path, work_dir, timeout, Some(&live_log_path))
|
||||
crate::builder::run_nix_build(
|
||||
&build.drv_path,
|
||||
work_dir,
|
||||
timeout,
|
||||
Some(&live_log_path),
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
|
|
@ -334,14 +358,11 @@ async fn run_build(
|
|||
|
||||
// Create sub-step records from parsed nix log
|
||||
for (i, sub_step) in build_result.sub_steps.iter().enumerate() {
|
||||
let sub = repo::build_steps::create(
|
||||
pool,
|
||||
CreateBuildStep {
|
||||
let sub = repo::build_steps::create(pool, CreateBuildStep {
|
||||
build_id: build.id,
|
||||
step_number: (i as i32) + 2,
|
||||
command: format!("nix build {}", sub_step.drv_path),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
let sub_exit = if sub_step.success { 0 } else { 1 };
|
||||
repo::build_steps::complete(pool, sub.id, sub_exit, None, None).await?;
|
||||
|
|
@ -353,11 +374,15 @@ async fn run_build(
|
|||
if live_log_path.exists() {
|
||||
let _ = tokio::fs::rename(&live_log_path, &final_path).await;
|
||||
} else {
|
||||
match storage.write_log(&build.id, &build_result.stdout, &build_result.stderr) {
|
||||
Ok(_) => {}
|
||||
match storage.write_log(
|
||||
&build.id,
|
||||
&build_result.stdout,
|
||||
&build_result.stderr,
|
||||
) {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
tracing::warn!(build_id = %build.id, "Failed to write build log: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Some(final_path.to_string_lossy().to_string())
|
||||
|
|
@ -397,23 +422,23 @@ async fn run_build(
|
|||
match gc_roots.register(&gc_id, output_path) {
|
||||
Ok(Some(link_path)) => {
|
||||
gc_root_path = Some(link_path.to_string_lossy().to_string());
|
||||
}
|
||||
Ok(None) => {}
|
||||
},
|
||||
Ok(None) => {},
|
||||
Err(e) => {
|
||||
tracing::warn!(build_id = %build.id, "Failed to register GC root: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get metadata from nix path-info
|
||||
let (sha256_hash, file_size) = match get_path_info(output_path).await {
|
||||
let (sha256_hash, file_size) = match get_path_info(output_path).await
|
||||
{
|
||||
Some((hash, size)) => (Some(hash), Some(size)),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
let product = repo::build_products::create(
|
||||
pool,
|
||||
CreateBuildProduct {
|
||||
let product =
|
||||
repo::build_products::create(pool, CreateBuildProduct {
|
||||
build_id: build.id,
|
||||
name: output_name,
|
||||
path: output_path.clone(),
|
||||
|
|
@ -421,13 +446,14 @@ async fn run_build(
|
|||
file_size,
|
||||
content_type: None,
|
||||
is_directory: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Update the build product with GC root path if registered
|
||||
if gc_root_path.is_some() {
|
||||
sqlx::query("UPDATE build_products SET gc_root_path = $1 WHERE id = $2")
|
||||
sqlx::query(
|
||||
"UPDATE build_products SET gc_root_path = $1 WHERE id = $2",
|
||||
)
|
||||
.bind(&gc_root_path)
|
||||
.bind(product.id)
|
||||
.execute(pool)
|
||||
|
|
@ -442,11 +468,13 @@ async fn run_build(
|
|||
|
||||
// Push to external binary cache if configured
|
||||
if cache_upload_config.enabled
|
||||
&& let Some(ref store_uri) = cache_upload_config.store_uri {
|
||||
&& let Some(ref store_uri) = cache_upload_config.store_uri
|
||||
{
|
||||
push_to_cache(&build_result.output_paths, store_uri).await;
|
||||
}
|
||||
|
||||
let primary_output = build_result.output_paths.first().map(|s| s.as_str());
|
||||
let primary_output =
|
||||
build_result.output_paths.first().map(|s| s.as_str());
|
||||
|
||||
repo::builds::complete(
|
||||
pool,
|
||||
|
|
@ -470,8 +498,7 @@ async fn run_build(
|
|||
);
|
||||
sqlx::query(
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL, \
|
||||
retry_count = retry_count + 1, completed_at = NULL \
|
||||
WHERE id = $1",
|
||||
retry_count = retry_count + 1, completed_at = NULL WHERE id = $1",
|
||||
)
|
||||
.bind(build.id)
|
||||
.execute(pool)
|
||||
|
|
@ -493,7 +520,7 @@ async fn run_build(
|
|||
|
||||
tracing::warn!(build_id = %build.id, "Build failed");
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
|
||||
|
|
@ -505,17 +532,27 @@ async fn run_build(
|
|||
let _ = tokio::fs::remove_file(&live_log_path).await;
|
||||
|
||||
repo::build_steps::complete(pool, step.id, 1, None, Some(&msg)).await?;
|
||||
repo::builds::complete(pool, build.id, BuildStatus::Failed, None, None, Some(&msg))
|
||||
repo::builds::complete(
|
||||
pool,
|
||||
build.id,
|
||||
BuildStatus::Failed,
|
||||
None,
|
||||
None,
|
||||
Some(&msg),
|
||||
)
|
||||
.await?;
|
||||
tracing::error!(build_id = %build.id, "Build error: {msg}");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Dispatch notifications after build completion
|
||||
let updated_build = repo::builds::get(pool, build.id).await?;
|
||||
if updated_build.status == BuildStatus::Completed || updated_build.status == BuildStatus::Failed
|
||||
if updated_build.status == BuildStatus::Completed
|
||||
|| updated_build.status == BuildStatus::Failed
|
||||
{
|
||||
if let Some((project, commit_hash)) =
|
||||
get_project_for_build(pool, build).await
|
||||
{
|
||||
if let Some((project, commit_hash)) = get_project_for_build(pool, build).await {
|
||||
fc_common::notifications::dispatch_build_finished(
|
||||
&updated_build,
|
||||
&project,
|
||||
|
|
@ -527,9 +564,11 @@ async fn run_build(
|
|||
|
||||
// Auto-promote channels if all builds in the evaluation are done
|
||||
if updated_build.status == BuildStatus::Completed
|
||||
&& let Ok(eval) = repo::evaluations::get(pool, build.evaluation_id).await {
|
||||
&& let Ok(eval) = repo::evaluations::get(pool, build.evaluation_id).await
|
||||
{
|
||||
let _ =
|
||||
repo::channels::auto_promote_if_complete(pool, eval.jobset_id, eval.id).await;
|
||||
repo::channels::auto_promote_if_complete(pool, eval.jobset_id, eval.id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
|
||||
#[test]
|
||||
fn test_parse_nix_log_start() {
|
||||
let line = r#"@nix {"action":"start","derivation":"/nix/store/abc-hello.drv"}"#;
|
||||
let line =
|
||||
r#"@nix {"action":"start","derivation":"/nix/store/abc-hello.drv"}"#;
|
||||
let result = fc_queue_runner::builder::parse_nix_log_line(line);
|
||||
assert!(result.is_some());
|
||||
let (action, drv) = result.unwrap();
|
||||
|
|
@ -16,7 +17,8 @@ fn test_parse_nix_log_start() {
|
|||
|
||||
#[test]
|
||||
fn test_parse_nix_log_stop() {
|
||||
let line = r#"@nix {"action":"stop","derivation":"/nix/store/abc-hello.drv"}"#;
|
||||
let line =
|
||||
r#"@nix {"action":"stop","derivation":"/nix/store/abc-hello.drv"}"#;
|
||||
let result = fc_queue_runner::builder::parse_nix_log_line(line);
|
||||
assert!(result.is_some());
|
||||
let (action, drv) = result.unwrap();
|
||||
|
|
@ -68,7 +70,7 @@ async fn test_worker_pool_drain_stops_dispatch() {
|
|||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -93,7 +95,8 @@ async fn test_worker_pool_drain_stops_dispatch() {
|
|||
worker_pool.drain();
|
||||
|
||||
// After drain, dispatching should be a no-op (build won't start)
|
||||
// We can't easily test this without a real build, but at least verify drain doesn't crash
|
||||
// We can't easily test this without a real build, but at least verify drain
|
||||
// doesn't crash
|
||||
}
|
||||
|
||||
// --- Database-dependent tests ---
|
||||
|
|
@ -105,7 +108,7 @@ async fn test_atomic_build_claiming() {
|
|||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -131,9 +134,8 @@ async fn test_atomic_build_claiming() {
|
|||
.await
|
||||
.expect("create project");
|
||||
|
||||
let jobset = fc_common::repo::jobsets::create(
|
||||
&pool,
|
||||
fc_common::models::CreateJobset {
|
||||
let jobset =
|
||||
fc_common::repo::jobsets::create(&pool, fc_common::models::CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "main".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
|
|
@ -142,8 +144,7 @@ async fn test_atomic_build_claiming() {
|
|||
check_interval: None,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
|
|
@ -157,9 +158,8 @@ async fn test_atomic_build_claiming() {
|
|||
.await
|
||||
.expect("create eval");
|
||||
|
||||
let build = fc_common::repo::builds::create(
|
||||
&pool,
|
||||
fc_common::models::CreateBuild {
|
||||
let build =
|
||||
fc_common::repo::builds::create(&pool, fc_common::models::CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: "test-build".to_string(),
|
||||
drv_path: "/nix/store/test-runner-test.drv".to_string(),
|
||||
|
|
@ -167,8 +167,7 @@ async fn test_atomic_build_claiming() {
|
|||
outputs: None,
|
||||
is_aggregate: None,
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create build");
|
||||
|
||||
|
|
@ -197,7 +196,7 @@ async fn test_orphan_build_reset() {
|
|||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -222,9 +221,8 @@ async fn test_orphan_build_reset() {
|
|||
.await
|
||||
.expect("create project");
|
||||
|
||||
let jobset = fc_common::repo::jobsets::create(
|
||||
&pool,
|
||||
fc_common::models::CreateJobset {
|
||||
let jobset =
|
||||
fc_common::repo::jobsets::create(&pool, fc_common::models::CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "main".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
|
|
@ -233,8 +231,7 @@ async fn test_orphan_build_reset() {
|
|||
check_interval: None,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
|
|
@ -249,9 +246,8 @@ async fn test_orphan_build_reset() {
|
|||
.expect("create eval");
|
||||
|
||||
// Create a build and mark it running
|
||||
let build = fc_common::repo::builds::create(
|
||||
&pool,
|
||||
fc_common::models::CreateBuild {
|
||||
let build =
|
||||
fc_common::repo::builds::create(&pool, fc_common::models::CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: "orphan-build".to_string(),
|
||||
drv_path: "/nix/store/test-orphan.drv".to_string(),
|
||||
|
|
@ -259,15 +255,18 @@ async fn test_orphan_build_reset() {
|
|||
outputs: None,
|
||||
is_aggregate: None,
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create build");
|
||||
|
||||
let _ = fc_common::repo::builds::start(&pool, build.id).await;
|
||||
|
||||
// Simulate the build being stuck for a while by manually backdating started_at
|
||||
sqlx::query("UPDATE builds SET started_at = NOW() - INTERVAL '10 minutes' WHERE id = $1")
|
||||
// Simulate the build being stuck for a while by manually backdating
|
||||
// started_at
|
||||
sqlx::query(
|
||||
"UPDATE builds SET started_at = NOW() - INTERVAL '10 minutes' WHERE id = \
|
||||
$1",
|
||||
)
|
||||
.bind(build.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -7,31 +7,31 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
tower-http.workspace = true
|
||||
tower.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
hmac.workspace = true
|
||||
tokio-util.workspace = true
|
||||
async-stream.workspace = true
|
||||
futures.workspace = true
|
||||
axum-extra.workspace = true
|
||||
dashmap.workspace = true
|
||||
askama.workspace = true
|
||||
askama_axum.workspace = true
|
||||
async-stream.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
dashmap.workspace = true
|
||||
futures.workspace = true
|
||||
hex.workspace = true
|
||||
hmac.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
# Our crates
|
||||
fc-common.workspace = true
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ use sha2::{Digest, Sha256};
|
|||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extract and validate an API key from the Authorization header or session cookie.
|
||||
/// Keys use the format: `Bearer fc_xxxx`. Session cookies use `fc_session=<id>`.
|
||||
/// Write endpoints (POST/PUT/DELETE/PATCH) require a valid key.
|
||||
/// Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for dashboard admin UI).
|
||||
/// Extract and validate an API key from the Authorization header or session
|
||||
/// cookie. Keys use the format: `Bearer fc_xxxx`. Session cookies use
|
||||
/// `fc_session=<id>`. Write endpoints (POST/PUT/DELETE/PATCH) require a valid
|
||||
/// key. Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for
|
||||
/// dashboard admin UI).
|
||||
pub async fn require_api_key(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
|
|
@ -60,7 +61,9 @@ pub async fn require_api_key(
|
|||
.and_then(|v| v.to_str().ok())
|
||||
&& let Some(session_id) = parse_cookie(cookie_header, "fc_session")
|
||||
&& let Some(session) = state.sessions.get(&session_id)
|
||||
&& session.created_at.elapsed() < std::time::Duration::from_secs(24 * 60 * 60) {
|
||||
&& session.created_at.elapsed()
|
||||
< std::time::Duration::from_secs(24 * 60 * 60)
|
||||
{
|
||||
request.extensions_mut().insert(session.api_key.clone());
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
|
@ -100,7 +103,8 @@ impl FromRequestParts<AppState> for RequireAdmin {
|
|||
/// Extractor that requires one of the specified roles (admin always passes).
|
||||
/// Use as: `_auth: RequireRole<"cancel-build", "restart-jobs">`
|
||||
///
|
||||
/// Since const generics with strings aren't stable, use the helper function instead.
|
||||
/// Since const generics with strings aren't stable, use the helper function
|
||||
/// instead.
|
||||
pub struct RequireRoles(pub ApiKey);
|
||||
|
||||
impl RequireRoles {
|
||||
|
|
@ -132,9 +136,12 @@ pub async fn extract_session(
|
|||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
&& let Some(session_id) = parse_cookie(cookie_header, "fc_session")
|
||||
&& let Some(session) = state.sessions.get(&session_id) {
|
||||
&& let Some(session) = state.sessions.get(&session_id)
|
||||
{
|
||||
// Check session expiry (24 hours)
|
||||
if session.created_at.elapsed() < std::time::Duration::from_secs(24 * 60 * 60) {
|
||||
if session.created_at.elapsed()
|
||||
< std::time::Duration::from_secs(24 * 60 * 60)
|
||||
{
|
||||
request.extensions_mut().insert(session.api_key.clone());
|
||||
} else {
|
||||
// Expired, remove it
|
||||
|
|
|
|||
|
|
@ -16,23 +16,39 @@ impl From<CiError> for ApiError {
|
|||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code, message) = match &self.0 {
|
||||
CiError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
|
||||
CiError::Validation(msg) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR", msg.clone()),
|
||||
CiError::NotFound(msg) => {
|
||||
(StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone())
|
||||
},
|
||||
CiError::Validation(msg) => {
|
||||
(StatusCode::BAD_REQUEST, "VALIDATION_ERROR", msg.clone())
|
||||
},
|
||||
CiError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()),
|
||||
CiError::Timeout(msg) => (StatusCode::REQUEST_TIMEOUT, "TIMEOUT", msg.clone()),
|
||||
CiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.clone()),
|
||||
CiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()),
|
||||
CiError::NixEval(msg) => (
|
||||
CiError::Timeout(msg) => {
|
||||
(StatusCode::REQUEST_TIMEOUT, "TIMEOUT", msg.clone())
|
||||
},
|
||||
CiError::Unauthorized(msg) => {
|
||||
(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.clone())
|
||||
},
|
||||
CiError::Forbidden(msg) => {
|
||||
(StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone())
|
||||
},
|
||||
CiError::NixEval(msg) => {
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"NIX_EVAL_ERROR",
|
||||
msg.clone(),
|
||||
),
|
||||
CiError::Build(msg) => (StatusCode::UNPROCESSABLE_ENTITY, "BUILD_ERROR", msg.clone()),
|
||||
CiError::Config(msg) => (
|
||||
)
|
||||
},
|
||||
CiError::Build(msg) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, "BUILD_ERROR", msg.clone())
|
||||
},
|
||||
CiError::Config(msg) => {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"CONFIG_ERROR",
|
||||
msg.clone(),
|
||||
),
|
||||
)
|
||||
},
|
||||
CiError::Database(e) => {
|
||||
tracing::error!(error = %e, "Database error in API handler");
|
||||
(
|
||||
|
|
@ -40,7 +56,7 @@ impl IntoResponse for ApiError {
|
|||
"DATABASE_ERROR",
|
||||
"Internal database error".to_string(),
|
||||
)
|
||||
}
|
||||
},
|
||||
CiError::Git(e) => {
|
||||
tracing::error!(error = %e, "Git error in API handler");
|
||||
(
|
||||
|
|
@ -48,7 +64,7 @@ impl IntoResponse for ApiError {
|
|||
"GIT_ERROR",
|
||||
format!("Git operation failed: {e}"),
|
||||
)
|
||||
}
|
||||
},
|
||||
CiError::Serialization(e) => {
|
||||
tracing::error!(error = %e, "Serialization error in API handler");
|
||||
(
|
||||
|
|
@ -56,7 +72,7 @@ impl IntoResponse for ApiError {
|
|||
"SERIALIZATION_ERROR",
|
||||
format!("Data serialization error: {e}"),
|
||||
)
|
||||
}
|
||||
},
|
||||
CiError::Io(e) => {
|
||||
tracing::error!(error = %e, "IO error in API handler");
|
||||
(
|
||||
|
|
@ -64,7 +80,7 @@ impl IntoResponse for ApiError {
|
|||
"IO_ERROR",
|
||||
format!("IO error: {e}"),
|
||||
)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if status.is_server_error() {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
use fc_server::routes;
|
||||
use fc_server::state;
|
||||
|
||||
use clap::Parser;
|
||||
use fc_common::{Config, Database};
|
||||
use fc_server::{routes, state};
|
||||
use state::AppState;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
Json,
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
routing::get,
|
||||
};
|
||||
use fc_common::Validate;
|
||||
use fc_common::models::{CreateRemoteBuilder, RemoteBuilder, SystemStatus, UpdateRemoteBuilder};
|
||||
use fc_common::{
|
||||
Validate,
|
||||
models::{
|
||||
CreateRemoteBuilder,
|
||||
RemoteBuilder,
|
||||
SystemStatus,
|
||||
UpdateRemoteBuilder,
|
||||
},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireAdmin;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState};
|
||||
|
||||
async fn list_builders(
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -53,7 +59,8 @@ async fn update_builder(
|
|||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let builder = fc_common::repo::remote_builders::update(&state.pool, id, input)
|
||||
let builder =
|
||||
fc_common::repo::remote_builders::update(&state.pool, id, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(builder))
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireAdmin;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateApiKeyRequest {
|
||||
|
|
@ -48,7 +46,8 @@ async fn create_api_key(
|
|||
let key = format!("fc_{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let key_hash = hash_api_key(&key);
|
||||
|
||||
let api_key = repo::api_keys::create(&state.pool, &input.name, &key_hash, &role)
|
||||
let api_key =
|
||||
repo::api_keys::create(&state.pool, &input.name, &key_hash, &role)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
@ -68,12 +67,14 @@ async fn list_api_keys(
|
|||
|
||||
let infos: Vec<ApiKeyInfo> = keys
|
||||
.into_iter()
|
||||
.map(|k| ApiKeyInfo {
|
||||
.map(|k| {
|
||||
ApiKeyInfo {
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
role: k.role,
|
||||
created_at: k.created_at,
|
||||
last_used_at: k.last_used_at,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,20 +6,25 @@ use axum::{
|
|||
routing::get,
|
||||
};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
async fn build_badge(
|
||||
State(state): State<AppState>,
|
||||
Path((project_name, jobset_name, job_name)): Path<(String, String, String)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
// Find the project
|
||||
let project = fc_common::repo::projects::get_by_name(&state.pool, &project_name)
|
||||
let project =
|
||||
fc_common::repo::projects::get_by_name(&state.pool, &project_name)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
// Find the jobset
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(&state.pool, project.id, 1000, 0)
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(
|
||||
&state.pool,
|
||||
project.id,
|
||||
1000,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
@ -28,7 +33,7 @@ async fn build_badge(
|
|||
Some(j) => j,
|
||||
None => {
|
||||
return Ok(shield_svg("build", "not found", "#9f9f9f").into_response());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Get latest evaluation
|
||||
|
|
@ -39,29 +44,35 @@ async fn build_badge(
|
|||
let eval = match eval {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return Ok(shield_svg("build", "no evaluations", "#9f9f9f").into_response());
|
||||
}
|
||||
return Ok(
|
||||
shield_svg("build", "no evaluations", "#9f9f9f").into_response(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Find the build for this job
|
||||
let builds = fc_common::repo::builds::list_for_evaluation(&state.pool, eval.id)
|
||||
let builds =
|
||||
fc_common::repo::builds::list_for_evaluation(&state.pool, eval.id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let build = builds.iter().find(|b| b.job_name == job_name);
|
||||
|
||||
let (label, color) = match build {
|
||||
Some(b) => match b.status {
|
||||
Some(b) => {
|
||||
match b.status {
|
||||
fc_common::BuildStatus::Completed => ("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"),
|
||||
}
|
||||
},
|
||||
None => ("not found", "#9f9f9f"),
|
||||
};
|
||||
|
||||
Ok((
|
||||
Ok(
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", "image/svg+xml"),
|
||||
|
|
@ -69,7 +80,8 @@ async fn build_badge(
|
|||
],
|
||||
shield_svg("build", label, color),
|
||||
)
|
||||
.into_response())
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Latest successful build redirect
|
||||
|
|
@ -77,11 +89,17 @@ async fn latest_build(
|
|||
State(state): State<AppState>,
|
||||
Path((project_name, jobset_name, job_name)): Path<(String, String, String)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let project = fc_common::repo::projects::get_by_name(&state.pool, &project_name)
|
||||
let project =
|
||||
fc_common::repo::projects::get_by_name(&state.pool, &project_name)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(&state.pool, project.id, 1000, 0)
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(
|
||||
&state.pool,
|
||||
project.id,
|
||||
1000,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
@ -90,7 +108,7 @@ async fn latest_build(
|
|||
Some(j) => j,
|
||||
None => {
|
||||
return Ok((StatusCode::NOT_FOUND, "Jobset not found").into_response());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let eval = fc_common::repo::evaluations::get_latest(&state.pool, jobset.id)
|
||||
|
|
@ -100,11 +118,14 @@ async fn latest_build(
|
|||
let eval = match eval {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return Ok((StatusCode::NOT_FOUND, "No evaluations found").into_response());
|
||||
}
|
||||
return Ok(
|
||||
(StatusCode::NOT_FOUND, "No evaluations found").into_response(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
let builds = fc_common::repo::builds::list_for_evaluation(&state.pool, eval.id)
|
||||
let builds =
|
||||
fc_common::repo::builds::list_for_evaluation(&state.pool, eval.id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
@ -124,15 +145,19 @@ fn shield_svg(subject: &str, status: &str, color: &str) -> String {
|
|||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_width}\" height=\"20\">\n"
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_width}\" \
|
||||
height=\"20\">\n"
|
||||
));
|
||||
svg.push_str(" <linearGradient id=\"b\" x2=\"0\" y2=\"100%\">\n");
|
||||
svg.push_str(" <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n");
|
||||
svg.push_str(
|
||||
" <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n",
|
||||
);
|
||||
svg.push_str(" <stop offset=\"1\" stop-opacity=\".1\"/>\n");
|
||||
svg.push_str(" </linearGradient>\n");
|
||||
svg.push_str(" <mask id=\"a\">\n");
|
||||
svg.push_str(&format!(
|
||||
" <rect width=\"{total_width}\" height=\"20\" rx=\"3\" fill=\"#fff\"/>\n"
|
||||
" <rect width=\"{total_width}\" height=\"20\" rx=\"3\" \
|
||||
fill=\"#fff\"/>\n"
|
||||
));
|
||||
svg.push_str(" </mask>\n");
|
||||
svg.push_str(" <g mask=\"url(#a)\">\n");
|
||||
|
|
@ -140,21 +165,27 @@ fn shield_svg(subject: &str, status: &str, color: &str) -> String {
|
|||
" <rect width=\"{subject_width}\" height=\"20\" fill=\"#555\"/>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <rect x=\"{subject_width}\" width=\"{status_width}\" height=\"20\" fill=\"{color}\"/>\n"
|
||||
" <rect x=\"{subject_width}\" width=\"{status_width}\" height=\"20\" \
|
||||
fill=\"{color}\"/>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <rect width=\"{total_width}\" height=\"20\" fill=\"url(#b)\"/>\n"
|
||||
));
|
||||
svg.push_str(" </g>\n");
|
||||
svg.push_str(" <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n");
|
||||
svg.push_str(
|
||||
" <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu \
|
||||
Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n",
|
||||
);
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{subject_x}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{subject}</text>\n"
|
||||
" <text x=\"{subject_x}\" y=\"15\" fill=\"#010101\" \
|
||||
fill-opacity=\".3\">{subject}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{subject_x}\" y=\"14\">{subject}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{status_x}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{status}</text>\n"
|
||||
" <text x=\"{status_x}\" y=\"15\" fill=\"#010101\" \
|
||||
fill-opacity=\".3\">{status}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{status_x}\" y=\"14\">{status}</text>\n"
|
||||
|
|
|
|||
|
|
@ -1,20 +1,28 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
Json,
|
||||
Router,
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{Extensions, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use fc_common::{Build, BuildProduct, BuildStep, PaginatedResponse, PaginationParams};
|
||||
use fc_common::{
|
||||
Build,
|
||||
BuildProduct,
|
||||
BuildStep,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireRoles;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{auth_middleware::RequireRoles, error::ApiError, state::AppState};
|
||||
|
||||
fn check_role(extensions: &Extensions, allowed: &[&str]) -> Result<(), ApiError> {
|
||||
fn check_role(
|
||||
extensions: &Extensions,
|
||||
allowed: &[&str],
|
||||
) -> Result<(), ApiError> {
|
||||
RequireRoles::check(extensions, allowed)
|
||||
.map(|_| ())
|
||||
.map_err(|s| {
|
||||
|
|
@ -115,7 +123,8 @@ async fn list_build_products(
|
|||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<BuildProduct>>, ApiError> {
|
||||
let products = fc_common::repo::build_products::list_for_build(&state.pool, id)
|
||||
let products =
|
||||
fc_common::repo::build_products::list_for_build(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(products))
|
||||
|
|
@ -130,7 +139,9 @@ async fn build_stats(
|
|||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
async fn recent_builds(State(state): State<AppState>) -> Result<Json<Vec<Build>>, ApiError> {
|
||||
async fn recent_builds(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Build>>, ApiError> {
|
||||
let builds = fc_common::repo::builds::list_recent(&state.pool, 20)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
|
@ -173,7 +184,8 @@ async fn bump_build(
|
|||
) -> Result<Json<Build>, ApiError> {
|
||||
check_role(&extensions, &["bump-to-front"])?;
|
||||
let build = sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET priority = priority + 10 WHERE id = $1 AND status = 'pending' RETURNING *",
|
||||
"UPDATE builds SET priority = priority + 10 WHERE id = $1 AND status = \
|
||||
'pending' RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
|
|
@ -227,7 +239,7 @@ async fn download_build_product(
|
|||
return Err(ApiError(fc_common::CiError::Build(format!(
|
||||
"Failed to dump path: {e}"
|
||||
))));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let stdout = match child.stdout.take() {
|
||||
|
|
@ -236,7 +248,7 @@ async fn download_build_product(
|
|||
return Err(ApiError(fc_common::CiError::Build(
|
||||
"Failed to capture output".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let stream = tokio_util::io::ReaderStream::new(stdout);
|
||||
|
|
@ -244,7 +256,8 @@ async fn download_build_product(
|
|||
|
||||
let filename = product.path.rsplit('/').next().unwrap_or(&product.name);
|
||||
|
||||
Ok((
|
||||
Ok(
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", "application/x-nix-nar"),
|
||||
|
|
@ -255,7 +268,8 @@ async fn download_build_product(
|
|||
],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
.into_response(),
|
||||
)
|
||||
} else {
|
||||
// Serve file directly
|
||||
let file = tokio::fs::File::open(&product.path)
|
||||
|
|
@ -271,7 +285,8 @@ async fn download_build_product(
|
|||
.unwrap_or("application/octet-stream");
|
||||
let filename = product.path.rsplit('/').next().unwrap_or(&product.name);
|
||||
|
||||
Ok((
|
||||
Ok(
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", content_type),
|
||||
|
|
@ -282,7 +297,8 @@ async fn download_build_product(
|
|||
],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ use axum::{
|
|||
};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
/// Serve NARInfo for a store path hash.
|
||||
/// GET /nix-cache/{hash}.narinfo
|
||||
|
|
@ -79,7 +78,8 @@ async fn narinfo(
|
|||
.get("references")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
arr
|
||||
.iter()
|
||||
.filter_map(|r| r.as_str())
|
||||
.map(|s| s.strip_prefix("/nix/store/").unwrap_or(s))
|
||||
.collect()
|
||||
|
|
@ -98,14 +98,9 @@ async fn narinfo(
|
|||
let file_hash = nar_hash;
|
||||
|
||||
let mut narinfo_text = format!(
|
||||
"StorePath: {store_path}\n\
|
||||
URL: nar/{hash}.nar.zst\n\
|
||||
Compression: zstd\n\
|
||||
FileHash: {file_hash}\n\
|
||||
FileSize: {nar_size}\n\
|
||||
NarHash: {nar_hash}\n\
|
||||
NarSize: {nar_size}\n\
|
||||
References: {refs}\n",
|
||||
"StorePath: {store_path}\nURL: nar/{hash}.nar.zst\nCompression: \
|
||||
zstd\nFileHash: {file_hash}\nFileSize: {nar_size}\nNarHash: \
|
||||
{nar_hash}\nNarSize: {nar_size}\nReferences: {refs}\n",
|
||||
store_path = store_path,
|
||||
hash = hash,
|
||||
file_hash = file_hash,
|
||||
|
|
@ -122,7 +117,8 @@ async fn narinfo(
|
|||
}
|
||||
|
||||
// Optionally sign if secret key is configured
|
||||
let narinfo_text = if let Some(ref key_file) = state.config.cache.secret_key_file {
|
||||
let narinfo_text =
|
||||
if let Some(ref key_file) = state.config.cache.secret_key_file {
|
||||
if key_file.exists() {
|
||||
sign_narinfo(&narinfo_text, key_file).await
|
||||
} else {
|
||||
|
|
@ -132,12 +128,14 @@ async fn narinfo(
|
|||
narinfo_text
|
||||
};
|
||||
|
||||
Ok((
|
||||
Ok(
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/x-nix-narinfo")],
|
||||
narinfo_text,
|
||||
)
|
||||
.into_response())
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Sign narinfo using nix store sign command
|
||||
|
|
@ -171,7 +169,8 @@ async fn sign_narinfo(narinfo: &str, key_file: &std::path::Path) -> String {
|
|||
.await;
|
||||
|
||||
if let Ok(o) = re_output
|
||||
&& let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&o.stdout)
|
||||
&& let Ok(parsed) =
|
||||
serde_json::from_slice::<serde_json::Value>(&o.stdout)
|
||||
&& let Some(sigs) = parsed
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
|
|
@ -188,7 +187,7 @@ async fn sign_narinfo(narinfo: &str, key_file: &std::path::Path) -> String {
|
|||
}
|
||||
}
|
||||
narinfo.to_string()
|
||||
}
|
||||
},
|
||||
_ => narinfo.to_string(),
|
||||
}
|
||||
}
|
||||
|
|
@ -266,7 +265,10 @@ async fn serve_nar_zst(
|
|||
let stream = tokio_util::io::ReaderStream::new(zstd_stdout);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
Ok((StatusCode::OK, [("content-type", "application/zstd")], body).into_response())
|
||||
Ok(
|
||||
(StatusCode::OK, [("content-type", "application/zstd")], body)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Serve an uncompressed NAR file for a store path (legacy).
|
||||
|
|
@ -321,12 +323,14 @@ async fn serve_nar(
|
|||
let stream = tokio_util::io::ReaderStream::new(stdout);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
Ok((
|
||||
Ok(
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("content-type", "application/x-nix-nar")],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Combined NAR handler — dispatches to zstd or plain based on suffix.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
Json,
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
};
|
||||
use fc_common::Validate;
|
||||
use fc_common::models::{Channel, CreateChannel};
|
||||
use fc_common::{
|
||||
Validate,
|
||||
models::{Channel, CreateChannel},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireAdmin;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState};
|
||||
|
||||
async fn list_channels(State(state): State<AppState>) -> Result<Json<Vec<Channel>>, ApiError> {
|
||||
async fn list_channels(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Channel>>, ApiError> {
|
||||
let channels = fc_common::repo::channels::list_all(&state.pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
|
@ -22,7 +25,8 @@ async fn list_project_channels(
|
|||
State(state): State<AppState>,
|
||||
Path(project_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Channel>>, ApiError> {
|
||||
let channels = fc_common::repo::channels::list_for_project(&state.pool, project_id)
|
||||
let channels =
|
||||
fc_common::repo::channels::list_for_project(&state.pool, project_id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(channels))
|
||||
|
|
@ -68,7 +72,8 @@ async fn promote_channel(
|
|||
State(state): State<AppState>,
|
||||
Path((channel_id, eval_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Channel>, ApiError> {
|
||||
let channel = fc_common::repo::channels::promote(&state.pool, channel_id, eval_id)
|
||||
let channel =
|
||||
fc_common::repo::channels::promote(&state.pool, channel_id, eval_id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(channel))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use askama::Template;
|
||||
use axum::{
|
||||
Form, Router,
|
||||
Form,
|
||||
Router,
|
||||
extract::{Path, Query, State},
|
||||
http::Extensions,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
|
|
@ -90,7 +91,7 @@ fn format_duration(
|
|||
} else {
|
||||
format!("{rem}s")
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +113,10 @@ fn build_view(b: &Build) -> BuildView {
|
|||
.completed_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_default(),
|
||||
duration: format_duration(b.started_at.as_ref(), b.completed_at.as_ref()),
|
||||
duration: format_duration(
|
||||
b.started_at.as_ref(),
|
||||
b.completed_at.as_ref(),
|
||||
),
|
||||
priority: b.priority,
|
||||
is_aggregate: b.is_aggregate,
|
||||
signed: b.signed,
|
||||
|
|
@ -143,7 +147,11 @@ fn eval_view(e: &Evaluation) -> EvalView {
|
|||
}
|
||||
}
|
||||
|
||||
fn eval_view_with_context(e: &Evaluation, jobset_name: &str, project_name: &str) -> EvalView {
|
||||
fn eval_view_with_context(
|
||||
e: &Evaluation,
|
||||
jobset_name: &str,
|
||||
project_name: &str,
|
||||
) -> EvalView {
|
||||
let mut v = eval_view(e);
|
||||
v.jobset_name = jobset_name.to_string();
|
||||
v.project_name = project_name.to_string();
|
||||
|
|
@ -332,14 +340,18 @@ struct LoginTemplate {
|
|||
|
||||
// --- Handlers ---
|
||||
|
||||
async fn home(State(state): State<AppState>, extensions: Extensions) -> Html<String> {
|
||||
async fn home(
|
||||
State(state): State<AppState>,
|
||||
extensions: Extensions,
|
||||
) -> Html<String> {
|
||||
let stats = fc_common::repo::builds::get_stats(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let builds = fc_common::repo::builds::list_recent(&state.pool, 10)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let evals = fc_common::repo::evaluations::list_filtered(&state.pool, None, None, 5, 0)
|
||||
let evals =
|
||||
fc_common::repo::evaluations::list_filtered(&state.pool, None, None, 5, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
|
|
@ -349,16 +361,23 @@ async fn home(State(state): State<AppState>, extensions: Extensions) -> Html<Str
|
|||
.unwrap_or_default();
|
||||
let mut project_summaries = Vec::new();
|
||||
for p in &all_projects {
|
||||
let jobset_count = fc_common::repo::jobsets::count_for_project(&state.pool, p.id)
|
||||
let jobset_count =
|
||||
fc_common::repo::jobsets::count_for_project(&state.pool, p.id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(&state.pool, p.id, 100, 0)
|
||||
let jobsets =
|
||||
fc_common::repo::jobsets::list_for_project(&state.pool, p.id, 100, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut last_eval: Option<Evaluation> = None;
|
||||
for js in &jobsets {
|
||||
let js_evals =
|
||||
fc_common::repo::evaluations::list_filtered(&state.pool, Some(js.id), None, 1, 0)
|
||||
let js_evals = fc_common::repo::evaluations::list_filtered(
|
||||
&state.pool,
|
||||
Some(js.id),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if let Some(e) = js_evals.into_iter().next()
|
||||
|
|
@ -373,7 +392,7 @@ async fn home(State(state): State<AppState>, extensions: Extensions) -> Html<Str
|
|||
Some(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 {
|
||||
|
|
@ -399,7 +418,8 @@ async fn home(State(state): State<AppState>, extensions: Extensions) -> Html<Str
|
|||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -439,7 +459,8 @@ async fn projects_page(
|
|||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -453,15 +474,21 @@ async fn project_page(
|
|||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
};
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(&state.pool, id, 100, 0)
|
||||
let jobsets =
|
||||
fc_common::repo::jobsets::list_for_project(&state.pool, id, 100, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get evaluations for this project's jobsets
|
||||
let mut evals = Vec::new();
|
||||
for js in &jobsets {
|
||||
let mut js_evals =
|
||||
fc_common::repo::evaluations::list_filtered(&state.pool, Some(js.id), None, 5, 0)
|
||||
let mut js_evals = fc_common::repo::evaluations::list_filtered(
|
||||
&state.pool,
|
||||
Some(js.id),
|
||||
None,
|
||||
5,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
evals.append(&mut js_evals);
|
||||
|
|
@ -477,22 +504,37 @@ async fn project_page(
|
|||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn jobset_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Html<String> {
|
||||
async fn jobset_page(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Html<String> {
|
||||
let jobset = match fc_common::repo::jobsets::get(&state.pool, id).await {
|
||||
Ok(j) => j,
|
||||
Err(_) => return Html("Jobset not found".to_string()),
|
||||
};
|
||||
let project = match fc_common::repo::projects::get(&state.pool, jobset.project_id).await {
|
||||
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 evals = fc_common::repo::evaluations::list_filtered(&state.pool, Some(id), None, 20, 0)
|
||||
let evals = fc_common::repo::evaluations::list_filtered(
|
||||
&state.pool,
|
||||
Some(id),
|
||||
None,
|
||||
20,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
|
|
@ -550,7 +592,8 @@ async fn jobset_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Htm
|
|||
eval_summaries: summaries,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -561,24 +604,33 @@ async fn evaluations_page(
|
|||
) -> Html<String> {
|
||||
let limit = params.limit.unwrap_or(50).min(200).max(1);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
let items = fc_common::repo::evaluations::list_filtered(&state.pool, None, None, limit, offset)
|
||||
let items = fc_common::repo::evaluations::list_filtered(
|
||||
&state.pool,
|
||||
None,
|
||||
None,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let total = fc_common::repo::evaluations::count_filtered(&state.pool, None, None)
|
||||
let total =
|
||||
fc_common::repo::evaluations::count_filtered(&state.pool, None, None)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
// Enrich evaluations with jobset/project names
|
||||
let mut enriched = Vec::new();
|
||||
for e in &items {
|
||||
let (jname, pname) = match fc_common::repo::jobsets::get(&state.pool, e.jobset_id).await {
|
||||
let (jname, pname) =
|
||||
match fc_common::repo::jobsets::get(&state.pool, e.jobset_id).await {
|
||||
Ok(js) => {
|
||||
let pname = fc_common::repo::projects::get(&state.pool, js.project_id)
|
||||
let pname =
|
||||
fc_common::repo::projects::get(&state.pool, js.project_id)
|
||||
.await
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|_| "-".to_string());
|
||||
(js.name, pname)
|
||||
}
|
||||
},
|
||||
Err(_) => ("-".to_string(), "-".to_string()),
|
||||
};
|
||||
enriched.push(eval_view_with_context(e, &jname, &pname));
|
||||
|
|
@ -597,28 +649,45 @@ async fn evaluations_page(
|
|||
total_pages,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn evaluation_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Html<String> {
|
||||
async fn evaluation_page(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Html<String> {
|
||||
let eval = match fc_common::repo::evaluations::get(&state.pool, id).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Html("Evaluation not found".to_string()),
|
||||
};
|
||||
|
||||
let jobset = match fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await {
|
||||
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 {
|
||||
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 builds =
|
||||
fc_common::repo::builds::list_filtered(&state.pool, Some(id), None, None, None, 200, 0)
|
||||
let builds = fc_common::repo::builds::list_filtered(
|
||||
&state.pool,
|
||||
Some(id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
200,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
|
|
@ -631,16 +700,31 @@ async fn evaluation_page(State(state): State<AppState>, Path(id): Path<Uuid>) ->
|
|||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let failed =
|
||||
fc_common::repo::builds::count_filtered(&state.pool, Some(id), Some("failed"), None, None)
|
||||
let failed = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
Some(id),
|
||||
Some("failed"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let running =
|
||||
fc_common::repo::builds::count_filtered(&state.pool, Some(id), Some("running"), None, None)
|
||||
let running = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
Some(id),
|
||||
Some("running"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let pending =
|
||||
fc_common::repo::builds::count_filtered(&state.pool, Some(id), Some("pending"), None, None)
|
||||
let pending = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
Some(id),
|
||||
Some("pending"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
|
|
@ -657,7 +741,8 @@ async fn evaluation_page(State(state): State<AppState>, Path(id): Path<Uuid>) ->
|
|||
pending_count: pending,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -714,26 +799,39 @@ async fn builds_page(
|
|||
filter_job: params.job_name.unwrap_or_default(),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn build_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Html<String> {
|
||||
async fn build_page(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Html<String> {
|
||||
let build = match fc_common::repo::builds::get(&state.pool, id).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Html("Build not found".to_string()),
|
||||
};
|
||||
|
||||
let eval = match fc_common::repo::evaluations::get(&state.pool, build.evaluation_id).await {
|
||||
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 {
|
||||
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 {
|
||||
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()),
|
||||
};
|
||||
|
|
@ -747,7 +845,8 @@ async fn build_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Html
|
|||
let steps = fc_common::repo::build_steps::list_for_build(&state.pool, id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let products = fc_common::repo::build_products::list_for_build(&state.pool, id)
|
||||
let products =
|
||||
fc_common::repo::build_products::list_for_build(&state.pool, id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
|
|
@ -763,7 +862,8 @@ async fn build_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Html
|
|||
project_name: project.name,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -802,7 +902,8 @@ async fn queue_page(State(state): State<AppState>) -> Html<String> {
|
|||
pending_count,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -814,12 +915,16 @@ async fn channels_page(State(state): State<AppState>) -> Html<String> {
|
|||
|
||||
let tmpl = ChannelsTemplate { channels };
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn admin_page(State(state): State<AppState>, extensions: Extensions) -> Html<String> {
|
||||
async fn admin_page(
|
||||
State(state): State<AppState>,
|
||||
extensions: Extensions,
|
||||
) -> Html<String> {
|
||||
let pool = &state.pool;
|
||||
|
||||
let projects: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects")
|
||||
|
|
@ -866,7 +971,8 @@ async fn admin_page(State(state): State<AppState>, extensions: Extensions) -> Ht
|
|||
.unwrap_or_default();
|
||||
let api_keys: Vec<ApiKeyView> = keys
|
||||
.into_iter()
|
||||
.map(|k| ApiKeyView {
|
||||
.map(|k| {
|
||||
ApiKeyView {
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
role: k.role,
|
||||
|
|
@ -875,6 +981,7 @@ async fn admin_page(State(state): State<AppState>, extensions: Extensions) -> Ht
|
|||
.last_used_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Never".to_string()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -886,7 +993,8 @@ async fn admin_page(State(state): State<AppState>, extensions: Extensions) -> Ht
|
|||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -899,7 +1007,8 @@ async fn project_setup_page(extensions: Extensions) -> Html<String> {
|
|||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -909,7 +1018,8 @@ async fn project_setup_page(extensions: Extensions) -> Html<String> {
|
|||
async fn login_page() -> Html<String> {
|
||||
let tmpl = LoginTemplate { error: None };
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
|
@ -919,14 +1029,18 @@ struct LoginForm {
|
|||
api_key: String,
|
||||
}
|
||||
|
||||
async fn login_action(State(state): State<AppState>, Form(form): Form<LoginForm>) -> Response {
|
||||
async fn login_action(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
let token = form.api_key.trim();
|
||||
if token.is_empty() {
|
||||
let tmpl = LoginTemplate {
|
||||
error: Some("API key is required".to_string()),
|
||||
};
|
||||
return Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
.into_response();
|
||||
|
|
@ -939,13 +1053,12 @@ async fn login_action(State(state): State<AppState>, Form(form): Form<LoginForm>
|
|||
match fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await {
|
||||
Ok(Some(api_key)) => {
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
state.sessions.insert(
|
||||
session_id.clone(),
|
||||
crate::state::SessionData {
|
||||
state
|
||||
.sessions
|
||||
.insert(session_id.clone(), crate::state::SessionData {
|
||||
api_key,
|
||||
created_at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let cookie = format!(
|
||||
"fc_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400",
|
||||
|
|
@ -956,21 +1069,25 @@ async fn login_action(State(state): State<AppState>, Form(form): Form<LoginForm>
|
|||
Redirect::to("/"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
let tmpl = LoginTemplate {
|
||||
error: Some("Invalid API key".to_string()),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
tmpl
|
||||
.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn logout_action(State(state): State<AppState>, request: axum::extract::Request) -> Response {
|
||||
async fn logout_action(
|
||||
State(state): State<AppState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Response {
|
||||
// Remove server-side session
|
||||
if let Some(cookie_header) = request
|
||||
.headers()
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
Json,
|
||||
Router,
|
||||
extract::{Path, Query, State},
|
||||
http::Extensions,
|
||||
routing::{get, post},
|
||||
};
|
||||
use fc_common::{CreateEvaluation, Evaluation, PaginatedResponse, PaginationParams, Validate};
|
||||
use fc_common::{
|
||||
CreateEvaluation,
|
||||
Evaluation,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
Validate,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireRoles;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{auth_middleware::RequireRoles, error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListEvaluationsParams {
|
||||
|
|
@ -132,10 +138,12 @@ async fn compare_evaluations(
|
|||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let from_builds = fc_common::repo::builds::list_for_evaluation(&state.pool, id)
|
||||
let from_builds =
|
||||
fc_common::repo::builds::list_for_evaluation(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let to_builds = fc_common::repo::builds::list_for_evaluation(&state.pool, params.to)
|
||||
let to_builds =
|
||||
fc_common::repo::builds::list_for_evaluation(&state.pool, params.to)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
Json,
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
routing::get,
|
||||
};
|
||||
|
|
@ -7,9 +8,7 @@ use fc_common::{Jobset, JobsetInput, UpdateJobset, Validate};
|
|||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireAdmin;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState};
|
||||
|
||||
async fn get_jobset(
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -53,7 +52,8 @@ async fn list_jobset_inputs(
|
|||
State(state): State<AppState>,
|
||||
Path((_project_id, jobset_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Vec<JobsetInput>>, ApiError> {
|
||||
let inputs = fc_common::repo::jobset_inputs::list_for_jobset(&state.pool, jobset_id)
|
||||
let inputs =
|
||||
fc_common::repo::jobset_inputs::list_for_jobset(&state.pool, jobset_id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(inputs))
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
use axum::response::sse::{Event, KeepAlive};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response, Sse},
|
||||
response::{
|
||||
IntoResponse,
|
||||
Response,
|
||||
Sse,
|
||||
sse::{Event, KeepAlive},
|
||||
},
|
||||
routing::get,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
async fn get_build_log(
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -20,17 +23,27 @@ async fn get_build_log(
|
|||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let log_storage = fc_common::log_storage::LogStorage::new(state.config.logs.log_dir.clone())
|
||||
let log_storage =
|
||||
fc_common::log_storage::LogStorage::new(state.config.logs.log_dir.clone())
|
||||
.map_err(|e| ApiError(fc_common::CiError::Io(e)))?;
|
||||
|
||||
match log_storage.read_log(&id) {
|
||||
Ok(Some(content)) => Ok((
|
||||
Ok(Some(content)) => {
|
||||
Ok(
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/plain; charset=utf-8")],
|
||||
content,
|
||||
)
|
||||
.into_response()),
|
||||
Ok(None) => Ok((StatusCode::NOT_FOUND, "No log available for this build").into_response()),
|
||||
.into_response(),
|
||||
)
|
||||
},
|
||||
Ok(None) => {
|
||||
Ok(
|
||||
(StatusCode::NOT_FOUND, "No log available for this build")
|
||||
.into_response(),
|
||||
)
|
||||
},
|
||||
Err(e) => Err(ApiError(fc_common::CiError::Io(e))),
|
||||
}
|
||||
}
|
||||
|
|
@ -38,12 +51,16 @@ async fn get_build_log(
|
|||
async fn stream_build_log(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>>, ApiError> {
|
||||
) -> Result<
|
||||
Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>>,
|
||||
ApiError,
|
||||
> {
|
||||
let build = fc_common::repo::builds::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let log_storage = fc_common::log_storage::LogStorage::new(state.config.logs.log_dir.clone())
|
||||
let log_storage =
|
||||
fc_common::log_storage::LogStorage::new(state.config.logs.log_dir.clone())
|
||||
.map_err(|e| ApiError(fc_common::CiError::Io(e)))?;
|
||||
|
||||
let active_path = log_storage.log_path_for_active(&id);
|
||||
|
|
|
|||
|
|
@ -13,10 +13,11 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let eval_count: i64 = match sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM evaluations")
|
||||
let eval_count: i64 =
|
||||
match sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM evaluations")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
{
|
||||
|
|
@ -24,17 +25,17 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
Err(_) => 0,
|
||||
};
|
||||
|
||||
let eval_by_status: Vec<(String, i64)> =
|
||||
sqlx::query_as("SELECT status::text, COUNT(*) FROM evaluations GROUP BY status")
|
||||
let eval_by_status: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT status::text, COUNT(*) FROM evaluations GROUP BY status",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let (project_count, channel_count, builder_count): (i64, i64, i64) = sqlx::query_as(
|
||||
"SELECT \
|
||||
(SELECT COUNT(*) FROM projects), \
|
||||
(SELECT COUNT(*) FROM channels), \
|
||||
(SELECT COUNT(*) FROM remote_builders WHERE enabled = true)",
|
||||
let (project_count, channel_count, builder_count): (i64, i64, i64) =
|
||||
sqlx::query_as(
|
||||
"SELECT (SELECT COUNT(*) FROM projects), (SELECT COUNT(*) FROM \
|
||||
channels), (SELECT COUNT(*) FROM remote_builders WHERE enabled = true)",
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
|
|
@ -42,30 +43,27 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
|
||||
// Per-project build counts
|
||||
let per_project: Vec<(String, i64, i64)> = sqlx::query_as(
|
||||
"SELECT p.name, \
|
||||
COUNT(*) FILTER (WHERE b.status = 'completed'), \
|
||||
COUNT(*) FILTER (WHERE b.status = 'failed') \
|
||||
FROM builds b \
|
||||
JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id \
|
||||
JOIN projects p ON j.project_id = p.id \
|
||||
GROUP BY p.name",
|
||||
"SELECT p.name, COUNT(*) FILTER (WHERE b.status = 'completed'), COUNT(*) \
|
||||
FILTER (WHERE b.status = 'failed') FROM builds b JOIN evaluations e ON \
|
||||
b.evaluation_id = e.id JOIN jobsets j ON e.jobset_id = j.id JOIN \
|
||||
projects p ON j.project_id = p.id GROUP BY p.name",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build duration percentiles (single query)
|
||||
let (duration_p50, duration_p95, duration_p99): (Option<f64>, Option<f64>, Option<f64>) =
|
||||
sqlx::query_as(
|
||||
"SELECT \
|
||||
(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY \
|
||||
EXTRACT(EPOCH FROM (completed_at - started_at)))), \
|
||||
(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY \
|
||||
EXTRACT(EPOCH FROM (completed_at - started_at)))), \
|
||||
(PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY \
|
||||
EXTRACT(EPOCH FROM (completed_at - started_at)))) \
|
||||
FROM builds WHERE completed_at IS NOT NULL AND started_at IS NOT NULL",
|
||||
let (duration_p50, duration_p95, duration_p99): (
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
) = sqlx::query_as(
|
||||
"SELECT (PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM \
|
||||
(completed_at - started_at)))), (PERCENTILE_CONT(0.95) WITHIN GROUP \
|
||||
(ORDER BY EXTRACT(EPOCH FROM (completed_at - started_at)))), \
|
||||
(PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM \
|
||||
(completed_at - started_at)))) FROM builds WHERE completed_at IS NOT \
|
||||
NULL AND started_at IS NOT NULL",
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
|
|
@ -98,14 +96,19 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
));
|
||||
|
||||
// Build duration stats
|
||||
output.push_str("\n# HELP fc_builds_avg_duration_seconds Average build duration in seconds\n");
|
||||
output.push_str(
|
||||
"\n# HELP fc_builds_avg_duration_seconds Average build duration in \
|
||||
seconds\n",
|
||||
);
|
||||
output.push_str("# TYPE fc_builds_avg_duration_seconds gauge\n");
|
||||
output.push_str(&format!(
|
||||
"fc_builds_avg_duration_seconds {:.2}\n",
|
||||
stats.avg_duration_seconds.unwrap_or(0.0)
|
||||
));
|
||||
|
||||
output.push_str("\n# HELP fc_builds_duration_seconds Build duration percentiles\n");
|
||||
output.push_str(
|
||||
"\n# HELP fc_builds_duration_seconds Build duration percentiles\n",
|
||||
);
|
||||
output.push_str("# TYPE fc_builds_duration_seconds gauge\n");
|
||||
if let Some(p50) = duration_p50 {
|
||||
output.push_str(&format!(
|
||||
|
|
@ -124,7 +127,8 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
}
|
||||
|
||||
// Evaluations
|
||||
output.push_str("\n# HELP fc_evaluations_total Total number of evaluations\n");
|
||||
output
|
||||
.push_str("\n# HELP fc_evaluations_total Total number of evaluations\n");
|
||||
output.push_str("# TYPE fc_evaluations_total gauge\n");
|
||||
output.push_str(&format!("fc_evaluations_total {}\n", eval_count));
|
||||
|
||||
|
|
@ -137,7 +141,8 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
}
|
||||
|
||||
// Queue depth (pending builds)
|
||||
output.push_str("\n# HELP fc_queue_depth Number of pending builds in queue\n");
|
||||
output
|
||||
.push_str("\n# HELP fc_queue_depth Number of pending builds in queue\n");
|
||||
output.push_str("# TYPE fc_queue_depth gauge\n");
|
||||
output.push_str(&format!(
|
||||
"fc_queue_depth {}\n",
|
||||
|
|
@ -153,20 +158,25 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
output.push_str("# TYPE fc_channels_total gauge\n");
|
||||
output.push_str(&format!("fc_channels_total {channel_count}\n"));
|
||||
|
||||
output.push_str("\n# HELP fc_remote_builders_active Active remote builders\n");
|
||||
output
|
||||
.push_str("\n# HELP fc_remote_builders_active Active remote builders\n");
|
||||
output.push_str("# TYPE fc_remote_builders_active gauge\n");
|
||||
output.push_str(&format!("fc_remote_builders_active {builder_count}\n"));
|
||||
|
||||
// Per-project build counts
|
||||
if !per_project.is_empty() {
|
||||
output.push_str("\n# HELP fc_project_builds_completed Completed builds per project\n");
|
||||
output.push_str(
|
||||
"\n# HELP fc_project_builds_completed Completed builds per project\n",
|
||||
);
|
||||
output.push_str("# TYPE fc_project_builds_completed gauge\n");
|
||||
for (name, completed, _) in &per_project {
|
||||
output.push_str(&format!(
|
||||
"fc_project_builds_completed{{project=\"{name}\"}} {completed}\n"
|
||||
));
|
||||
}
|
||||
output.push_str("\n# HELP fc_project_builds_failed Failed builds per project\n");
|
||||
output.push_str(
|
||||
"\n# HELP fc_project_builds_failed Failed builds per project\n",
|
||||
);
|
||||
output.push_str("# TYPE fc_project_builds_failed gauge\n");
|
||||
for (name, _, failed) in &per_project {
|
||||
output.push_str(&format!(
|
||||
|
|
|
|||
|
|
@ -14,28 +14,30 @@ pub mod projects;
|
|||
pub mod search;
|
||||
pub mod webhooks;
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::{net::IpAddr, sync::Arc, time::Instant};
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{HeaderValue, Request, StatusCode};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::ConnectInfo,
|
||||
http::{HeaderValue, Request, StatusCode, header},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use fc_common::config::ServerConfig;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tower_http::{
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
limit::RequestBodyLimitLayer,
|
||||
set_header::SetResponseHeaderLayer,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::header;
|
||||
|
||||
use crate::auth_middleware::{extract_session, require_api_key};
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
auth_middleware::{extract_session, require_api_key},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
static STYLE_CSS: &str = include_str!("../../static/style.css");
|
||||
|
||||
|
|
@ -76,7 +78,9 @@ async fn rate_limit_middleware(
|
|||
.is_ok()
|
||||
{
|
||||
rl.requests.retain(|_, v| {
|
||||
v.retain(|t| now.duration_since(*t) < std::time::Duration::from_secs(10));
|
||||
v.retain(|t| {
|
||||
now.duration_since(*t) < std::time::Duration::from_secs(10)
|
||||
});
|
||||
!v.is_empty()
|
||||
});
|
||||
}
|
||||
|
|
@ -170,7 +174,9 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router {
|
|||
));
|
||||
|
||||
// Add rate limiting if configured
|
||||
if let (Some(rps), Some(burst)) = (config.rate_limit_rps, config.rate_limit_burst) {
|
||||
if let (Some(rps), Some(burst)) =
|
||||
(config.rate_limit_rps, config.rate_limit_burst)
|
||||
{
|
||||
let rl_state = Arc::new(RateLimitState {
|
||||
requests: DashMap::new(),
|
||||
_rps: rps,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,29 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
Json,
|
||||
Router,
|
||||
extract::{Path, Query, State},
|
||||
http::Extensions,
|
||||
routing::{get, post},
|
||||
};
|
||||
use fc_common::nix_probe;
|
||||
use fc_common::{
|
||||
CreateJobset, CreateProject, Jobset, PaginatedResponse, PaginationParams, Project,
|
||||
UpdateProject, Validate,
|
||||
CreateJobset,
|
||||
CreateProject,
|
||||
Jobset,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
Project,
|
||||
UpdateProject,
|
||||
Validate,
|
||||
nix_probe,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::{RequireAdmin, RequireRoles};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
auth_middleware::{RequireAdmin, RequireRoles},
|
||||
error::ApiError,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
async fn list_projects(
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -100,7 +109,8 @@ async fn list_project_jobsets(
|
|||
) -> Result<Json<PaginatedResponse<Jobset>>, ApiError> {
|
||||
let limit = pagination.limit();
|
||||
let offset = pagination.offset();
|
||||
let items = fc_common::repo::jobsets::list_for_project(&state.pool, id, limit, offset)
|
||||
let items =
|
||||
fc_common::repo::jobsets::list_for_project(&state.pool, id, limit, offset)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let total = fc_common::repo::jobsets::count_for_project(&state.pool, id)
|
||||
|
|
@ -165,7 +175,8 @@ async fn probe_repository(
|
|||
_extensions: Extensions,
|
||||
Json(body): Json<ProbeRequest>,
|
||||
) -> Result<Json<nix_probe::FlakeProbeResult>, ApiError> {
|
||||
let result = nix_probe::probe_flake(&body.repository_url, body.revision.as_deref())
|
||||
let result =
|
||||
nix_probe::probe_flake(&body.repository_url, body.revision.as_deref())
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(result))
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
Json,
|
||||
Router,
|
||||
extract::{Query, State},
|
||||
routing::get,
|
||||
};
|
||||
use fc_common::models::{Build, Project};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchParams {
|
||||
|
|
@ -35,7 +35,8 @@ async fn search(
|
|||
let pattern = format!("%{query}%");
|
||||
|
||||
let projects = sqlx::query_as::<_, Project>(
|
||||
"SELECT * FROM projects WHERE name ILIKE $1 OR description ILIKE $1 ORDER BY name LIMIT 20",
|
||||
"SELECT * FROM projects WHERE name ILIKE $1 OR description ILIKE $1 ORDER \
|
||||
BY name LIMIT 20",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&state.pool)
|
||||
|
|
@ -43,7 +44,8 @@ async fn search(
|
|||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
let builds = sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE job_name ILIKE $1 OR drv_path ILIKE $1 ORDER BY created_at DESC LIMIT 20",
|
||||
"SELECT * FROM builds WHERE job_name ILIKE $1 OR drv_path ILIKE $1 ORDER \
|
||||
BY created_at DESC LIMIT 20",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&state.pool)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
Json,
|
||||
Router,
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::post,
|
||||
};
|
||||
use fc_common::models::CreateEvaluation;
|
||||
use fc_common::repo;
|
||||
use fc_common::{models::CreateEvaluation, repo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WebhookResponse {
|
||||
|
|
@ -82,8 +81,11 @@ async fn handle_github_push(
|
|||
body: Bytes,
|
||||
) -> Result<(StatusCode, Json<WebhookResponse>), ApiError> {
|
||||
// Check webhook config exists
|
||||
let webhook_config =
|
||||
repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, "github")
|
||||
let webhook_config = repo::webhook_configs::get_by_project_and_forge(
|
||||
&state.pool,
|
||||
project_id,
|
||||
"github",
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
@ -97,7 +99,7 @@ async fn handle_github_push(
|
|||
message: "No GitHub webhook configured for this project".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Verify signature if secret is configured
|
||||
|
|
@ -119,7 +121,8 @@ async fn handle_github_push(
|
|||
}
|
||||
|
||||
// Parse payload
|
||||
let payload: GithubPushPayload = serde_json::from_slice(&body).map_err(|e| {
|
||||
let payload: GithubPushPayload =
|
||||
serde_json::from_slice(&body).map_err(|e| {
|
||||
ApiError(fc_common::CiError::Validation(format!(
|
||||
"Invalid payload: {e}"
|
||||
)))
|
||||
|
|
@ -137,7 +140,8 @@ async fn handle_github_push(
|
|||
}
|
||||
|
||||
// Find matching jobsets for this project and trigger evaluations
|
||||
let jobsets = repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0)
|
||||
let jobsets =
|
||||
repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
@ -146,17 +150,14 @@ async fn handle_github_push(
|
|||
if !jobset.enabled {
|
||||
continue;
|
||||
}
|
||||
match repo::evaluations::create(
|
||||
&state.pool,
|
||||
CreateEvaluation {
|
||||
match repo::evaluations::create(&state.pool, CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: commit.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => triggered += 1,
|
||||
Err(fc_common::CiError::Conflict(_)) => {} // already exists
|
||||
Err(fc_common::CiError::Conflict(_)) => {}, // already exists
|
||||
Err(e) => tracing::warn!("Failed to create evaluation: {e}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -165,7 +166,9 @@ async fn handle_github_push(
|
|||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: format!("Triggered {triggered} evaluations for commit {commit}"),
|
||||
message: format!(
|
||||
"Triggered {triggered} evaluations for commit {commit}"
|
||||
),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
|
@ -183,8 +186,11 @@ async fn handle_gitea_push(
|
|||
"gitea"
|
||||
};
|
||||
|
||||
let webhook_config =
|
||||
repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, forge_type)
|
||||
let webhook_config = repo::webhook_configs::get_by_project_and_forge(
|
||||
&state.pool,
|
||||
project_id,
|
||||
forge_type,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
@ -197,7 +203,11 @@ async fn handle_gitea_push(
|
|||
} else {
|
||||
"gitea"
|
||||
};
|
||||
match repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, alt)
|
||||
match repo::webhook_configs::get_by_project_and_forge(
|
||||
&state.pool,
|
||||
project_id,
|
||||
alt,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?
|
||||
{
|
||||
|
|
@ -211,9 +221,9 @@ async fn handle_gitea_push(
|
|||
.to_string(),
|
||||
}),
|
||||
));
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Verify signature if configured
|
||||
|
|
@ -235,7 +245,8 @@ async fn handle_gitea_push(
|
|||
}
|
||||
}
|
||||
|
||||
let payload: GiteaPushPayload = serde_json::from_slice(&body).map_err(|e| {
|
||||
let payload: GiteaPushPayload =
|
||||
serde_json::from_slice(&body).map_err(|e| {
|
||||
ApiError(fc_common::CiError::Validation(format!(
|
||||
"Invalid payload: {e}"
|
||||
)))
|
||||
|
|
@ -252,7 +263,8 @@ async fn handle_gitea_push(
|
|||
));
|
||||
}
|
||||
|
||||
let jobsets = repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0)
|
||||
let jobsets =
|
||||
repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
|
|
@ -261,17 +273,14 @@ async fn handle_gitea_push(
|
|||
if !jobset.enabled {
|
||||
continue;
|
||||
}
|
||||
match repo::evaluations::create(
|
||||
&state.pool,
|
||||
CreateEvaluation {
|
||||
match repo::evaluations::create(&state.pool, CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: commit.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => triggered += 1,
|
||||
Err(fc_common::CiError::Conflict(_)) => {}
|
||||
Err(fc_common::CiError::Conflict(_)) => {},
|
||||
Err(e) => tracing::warn!("Failed to create evaluation: {e}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -280,7 +289,9 @@ async fn handle_gitea_push(
|
|||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: format!("Triggered {triggered} evaluations for commit {commit}"),
|
||||
message: format!(
|
||||
"Triggered {triggered} evaluations for commit {commit}"
|
||||
),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use fc_common::config::Config;
|
||||
use fc_common::models::ApiKey;
|
||||
use fc_common::{config::Config, models::ApiKey};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct SessionData {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
//! Integration tests for API endpoints.
|
||||
//! Requires TEST_DATABASE_URL to be set.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
|
|
@ -11,7 +13,7 @@ async fn get_pool() -> Option<sqlx::PgPool> {
|
|||
Err(_) => {
|
||||
println!("Skipping API test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -39,7 +41,10 @@ fn build_app(pool: sqlx::PgPool) -> axum::Router {
|
|||
fc_server::routes::router(state, &server_config)
|
||||
}
|
||||
|
||||
fn build_app_with_config(pool: sqlx::PgPool, config: fc_common::config::Config) -> axum::Router {
|
||||
fn build_app_with_config(
|
||||
pool: sqlx::PgPool,
|
||||
config: fc_common::config::Config,
|
||||
) -> axum::Router {
|
||||
let server_config = config.server.clone();
|
||||
let state = fc_server::state::AppState {
|
||||
pool,
|
||||
|
|
@ -790,7 +795,9 @@ async fn test_project_create_with_auth() {
|
|||
use sha2::Digest;
|
||||
hasher.update(b"fc_test_project_auth");
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
let _ = fc_common::repo::api_keys::upsert(&pool, "test-auth", &key_hash, "admin").await;
|
||||
let _ =
|
||||
fc_common::repo::api_keys::upsert(&pool, "test-auth", &key_hash, "admin")
|
||||
.await;
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
|
|
@ -863,7 +870,9 @@ async fn test_setup_endpoint_creates_project_and_jobsets() {
|
|||
use sha2::Digest;
|
||||
hasher.update(b"fc_test_setup_key");
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
let _ = fc_common::repo::api_keys::upsert(&pool, "test-setup", &key_hash, "admin").await;
|
||||
let _ =
|
||||
fc_common::repo::api_keys::upsert(&pool, "test-setup", &key_hash, "admin")
|
||||
.await;
|
||||
|
||||
let app = build_app(pool.clone());
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@
|
|||
//!
|
||||
//! Nix-dependent steps are skipped if nix is not available.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use fc_common::models::*;
|
||||
use tower::ServiceExt;
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ async fn get_pool() -> Option<sqlx::PgPool> {
|
|||
Err(_) => {
|
||||
println!("Skipping E2E test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -41,23 +43,18 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
|
||||
// 1. Create a project
|
||||
let project_name = format!("e2e-test-{}", uuid::Uuid::new_v4());
|
||||
let project = fc_common::repo::projects::create(
|
||||
&pool,
|
||||
CreateProject {
|
||||
let project = fc_common::repo::projects::create(&pool, CreateProject {
|
||||
name: project_name.clone(),
|
||||
description: Some("E2E test project".to_string()),
|
||||
repository_url: "https://github.com/test/e2e".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create project");
|
||||
|
||||
assert_eq!(project.name, project_name);
|
||||
|
||||
// 2. Create a jobset
|
||||
let jobset = fc_common::repo::jobsets::create(
|
||||
&pool,
|
||||
CreateJobset {
|
||||
let jobset = fc_common::repo::jobsets::create(&pool, CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "default".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
|
|
@ -66,8 +63,7 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
check_interval: Some(300),
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
|
|
@ -84,13 +80,10 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
);
|
||||
|
||||
// 4. Create an evaluation
|
||||
let eval = fc_common::repo::evaluations::create(
|
||||
&pool,
|
||||
CreateEvaluation {
|
||||
let eval = fc_common::repo::evaluations::create(&pool, CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: "e2e0000000000000000000000000000000000000".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create evaluation");
|
||||
|
||||
|
|
@ -98,14 +91,17 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
assert_eq!(eval.status, EvaluationStatus::Pending);
|
||||
|
||||
// 5. Mark evaluation as running
|
||||
fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Running, None)
|
||||
fc_common::repo::evaluations::update_status(
|
||||
&pool,
|
||||
eval.id,
|
||||
EvaluationStatus::Running,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("update eval status");
|
||||
|
||||
// 6. Create builds as if nix evaluation found jobs
|
||||
let build1 = fc_common::repo::builds::create(
|
||||
&pool,
|
||||
CreateBuild {
|
||||
let build1 = fc_common::repo::builds::create(&pool, CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: "hello".to_string(),
|
||||
drv_path: "/nix/store/e2e000-hello.drv".to_string(),
|
||||
|
|
@ -113,14 +109,11 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-hello"})),
|
||||
is_aggregate: Some(false),
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create build 1");
|
||||
|
||||
let build2 = fc_common::repo::builds::create(
|
||||
&pool,
|
||||
CreateBuild {
|
||||
let build2 = fc_common::repo::builds::create(&pool, CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: "world".to_string(),
|
||||
drv_path: "/nix/store/e2e000-world.drv".to_string(),
|
||||
|
|
@ -128,8 +121,7 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-world"})),
|
||||
is_aggregate: Some(false),
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create build 2");
|
||||
|
||||
|
|
@ -141,8 +133,10 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
.await
|
||||
.expect("create dependency");
|
||||
|
||||
// 8. Verify dependency check: build1 deps NOT complete (world is still pending)
|
||||
let deps_complete = fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
|
||||
// 8. Verify dependency check: build1 deps NOT complete (world is still
|
||||
// pending)
|
||||
let deps_complete =
|
||||
fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
|
||||
.await
|
||||
.expect("check deps");
|
||||
assert!(!deps_complete, "deps should NOT be complete yet");
|
||||
|
|
@ -163,7 +157,8 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
.expect("complete build2");
|
||||
|
||||
// 10. Now build1 deps should be complete
|
||||
let deps_complete = fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
|
||||
let deps_complete =
|
||||
fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
|
||||
.await
|
||||
.expect("check deps again");
|
||||
assert!(deps_complete, "deps should be complete after build2 done");
|
||||
|
|
@ -173,24 +168,25 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
.await
|
||||
.expect("start build1");
|
||||
|
||||
let step = fc_common::repo::build_steps::create(
|
||||
&pool,
|
||||
CreateBuildStep {
|
||||
let step = fc_common::repo::build_steps::create(&pool, CreateBuildStep {
|
||||
build_id: build1.id,
|
||||
step_number: 1,
|
||||
command: "nix build /nix/store/e2e000-hello.drv".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create step");
|
||||
|
||||
fc_common::repo::build_steps::complete(&pool, step.id, 0, Some("built!"), None)
|
||||
fc_common::repo::build_steps::complete(
|
||||
&pool,
|
||||
step.id,
|
||||
0,
|
||||
Some("built!"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("complete step");
|
||||
|
||||
fc_common::repo::build_products::create(
|
||||
&pool,
|
||||
CreateBuildProduct {
|
||||
fc_common::repo::build_products::create(&pool, CreateBuildProduct {
|
||||
build_id: build1.id,
|
||||
name: "out".to_string(),
|
||||
path: "/nix/store/e2e000-hello".to_string(),
|
||||
|
|
@ -198,8 +194,7 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
file_size: Some(12345),
|
||||
content_type: None,
|
||||
is_directory: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create product");
|
||||
|
||||
|
|
@ -215,7 +210,12 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
.expect("complete build1");
|
||||
|
||||
// 12. Mark evaluation as completed
|
||||
fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Completed, None)
|
||||
fc_common::repo::evaluations::update_status(
|
||||
&pool,
|
||||
eval.id,
|
||||
EvaluationStatus::Completed,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("complete eval");
|
||||
|
||||
|
|
@ -234,7 +234,8 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
Some("/nix/store/e2e000-hello")
|
||||
);
|
||||
|
||||
let products = fc_common::repo::build_products::list_for_build(&pool, build1.id)
|
||||
let products =
|
||||
fc_common::repo::build_products::list_for_build(&pool, build1.id)
|
||||
.await
|
||||
.expect("list products");
|
||||
assert_eq!(products.len(), 1);
|
||||
|
|
@ -253,14 +254,11 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
assert!(stats.completed_builds.unwrap_or(0) >= 2);
|
||||
|
||||
// 15. Create a channel and verify it works
|
||||
let channel = fc_common::repo::channels::create(
|
||||
&pool,
|
||||
CreateChannel {
|
||||
let channel = fc_common::repo::channels::create(&pool, CreateChannel {
|
||||
project_id: project.id,
|
||||
name: "stable".to_string(),
|
||||
jobset_id: jobset.id,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("create channel");
|
||||
|
||||
|
|
|
|||
20
fc.toml
20
fc.toml
|
|
@ -2,30 +2,30 @@
|
|||
# This file contains default configuration for all FC CI components
|
||||
|
||||
[database]
|
||||
url = "postgresql://fc_ci:password@localhost/fc_ci"
|
||||
max_connections = 20
|
||||
min_connections = 5
|
||||
connect_timeout = 30
|
||||
idle_timeout = 600
|
||||
max_connections = 20
|
||||
max_lifetime = 1800
|
||||
min_connections = 5
|
||||
url = "postgresql://fc_ci:password@localhost/fc_ci"
|
||||
|
||||
[server]
|
||||
allowed_origins = [ ]
|
||||
host = "127.0.0.1"
|
||||
max_body_size = 10485760 # 10MB
|
||||
port = 3000
|
||||
request_timeout = 30
|
||||
max_body_size = 10485760 # 10MB
|
||||
allowed_origins = []
|
||||
|
||||
[evaluator]
|
||||
poll_interval = 60
|
||||
allow_ifd = false
|
||||
git_timeout = 600
|
||||
nix_timeout = 1800
|
||||
work_dir = "/tmp/fc-evaluator"
|
||||
poll_interval = 60
|
||||
restrict_eval = true
|
||||
allow_ifd = false
|
||||
work_dir = "/tmp/fc-evaluator"
|
||||
|
||||
[queue_runner]
|
||||
workers = 4
|
||||
poll_interval = 5
|
||||
build_timeout = 3600
|
||||
poll_interval = 5
|
||||
work_dir = "/tmp/fc-queue-runner"
|
||||
workers = 4
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue