diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 7b1d4cd..c4dc7f6 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -20,4 +20,21 @@ anyhow.workspace = true thiserror.workspace = true clap.workspace = true config.workspace = true -fc-common = { path = "../common" } \ No newline at end of file +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 + +# Our crates +fc-common.workspace = true + +[dev-dependencies] +tower.workspace = true diff --git a/crates/server/src/auth_middleware.rs b/crates/server/src/auth_middleware.rs new file mode 100644 index 0000000..768c7bb --- /dev/null +++ b/crates/server/src/auth_middleware.rs @@ -0,0 +1,162 @@ +use axum::{ + extract::{FromRequestParts, Request, State}, + http::{StatusCode, request::Parts}, + middleware::Next, + response::Response, +}; +use fc_common::models::ApiKey; +use sha2::{Digest, Sha256}; + +use crate::state::AppState; + +/// Extract and validate an API key from the Authorization header. +/// Keys use the format: `Bearer fc_xxxx`. +/// 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, + mut request: Request, + next: Next, +) -> Result { + let method = request.method().clone(); + let is_read = method == axum::http::Method::GET + || method == axum::http::Method::HEAD + || method == axum::http::Method::OPTIONS; + + let auth_header = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(String::from); + + let token = auth_header + .as_deref() + .and_then(|h| h.strip_prefix("Bearer ")); + + match token { + Some(token) => { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let key_hash = hex::encode(hasher.finalize()); + + match fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await { + Ok(Some(api_key)) => { + // Touch last_used_at (fire and forget) + let pool = state.pool.clone(); + let key_id = api_key.id; + tokio::spawn(async move { + let _ = fc_common::repo::api_keys::touch_last_used(&pool, key_id).await; + }); + + request.extensions_mut().insert(api_key); + Ok(next.run(request).await) + } + _ => { + if is_read { + // Invalid token on read is still allowed, just no ApiKey in extensions + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } + } + } + None => { + if is_read { + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } + } +} + +/// Extractor that requires an authenticated admin user. +/// Use as a handler parameter: `_auth: RequireAdmin` +pub struct RequireAdmin(pub ApiKey); + +impl FromRequestParts for RequireAdmin { + type Rejection = StatusCode; + + async fn from_request_parts( + parts: &mut Parts, + _state: &AppState, + ) -> Result { + let key = parts + .extensions + .get::() + .cloned() + .ok_or(StatusCode::UNAUTHORIZED)?; + if key.role == "admin" { + Ok(RequireAdmin(key)) + } else { + Err(StatusCode::FORBIDDEN) + } + } +} + +/// 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. +pub struct RequireRoles(pub ApiKey); + +impl RequireRoles { + pub fn check( + extensions: &axum::http::Extensions, + allowed: &[&str], + ) -> Result { + let key = extensions + .get::() + .cloned() + .ok_or(StatusCode::UNAUTHORIZED)?; + if key.role == "admin" || allowed.contains(&key.role.as_str()) { + Ok(key) + } else { + Err(StatusCode::FORBIDDEN) + } + } +} + +/// Session extraction middleware for dashboard routes. +/// Reads `fc_session` cookie and inserts ApiKey into extensions if valid. +pub async fn extract_session( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + if let Some(cookie_header) = request + .headers() + .get("cookie") + .and_then(|v| v.to_str().ok()) + { + if let Some(session_id) = parse_cookie(cookie_header, "fc_session") { + if 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) { + request.extensions_mut().insert(session.api_key.clone()); + } else { + // Expired, remove it + drop(session); + state.sessions.remove(&session_id); + } + } + } + } + next.run(request).await +} + +fn parse_cookie<'a>(header: &'a str, name: &str) -> Option { + header + .split(';') + .filter_map(|pair| { + let pair = pair.trim(); + let (k, v) = pair.split_once('=')?; + if k.trim() == name { + Some(v.trim().to_string()) + } else { + None + } + }) + .next() +} diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs new file mode 100644 index 0000000..d116764 --- /dev/null +++ b/crates/server/src/error.rs @@ -0,0 +1,40 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use fc_common::CiError; +use serde_json::json; + +pub struct ApiError(pub CiError); + +impl From for ApiError { + fn from(err: CiError) -> Self { + ApiError(err) + } +} + +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::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::Database(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "DATABASE_ERROR", + "Internal database error".to_string(), + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "Internal server error".to_string(), + ), + }; + + let body = axum::Json(json!({ "error": message, "error_code": code })); + (status, body).into_response() + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs new file mode 100644 index 0000000..89b1a9d --- /dev/null +++ b/crates/server/src/lib.rs @@ -0,0 +1,4 @@ +pub mod auth_middleware; +pub mod error; +pub mod routes; +pub mod state; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 1363156..e11e1c7 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,22 +1,79 @@ +use fc_server::routes; +use fc_server::state; + use clap::Parser; -use tracing_subscriber::fmt::init; +use fc_common::{Config, Database}; +use state::AppState; +use tokio::net::TcpListener; #[derive(Parser)] #[command(name = "fc-server")] #[command(about = "CI Server - Web API and UI")] struct Cli { - #[arg(short, long, default_value = "3000")] - port: u16, + #[arg(short = 'H', long)] + host: Option, + + #[arg(short, long)] + port: Option, +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => {}, + () = terminate => {}, + } + + tracing::info!("Shutdown signal received"); } #[tokio::main] async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + let cli = Cli::parse(); + let config = Config::load()?; - tracing::info!("Starting CI Server on port {}", cli.port); - init(); + let host = cli.host.unwrap_or(config.server.host.clone()); + let port = cli.port.unwrap_or(config.server.port); - // TODO: Implement server logic + let db = Database::new(config.database.clone()).await?; + + let state = AppState { + pool: db.pool().clone(), + config: config.clone(), + sessions: std::sync::Arc::new(dashmap::DashMap::new()), + }; + + let app = routes::router(state, &config.server); + + let bind_addr = format!("{host}:{port}"); + tracing::info!("Starting CI Server on {}", bind_addr); + + let listener = TcpListener::bind(&bind_addr).await?; + let app = app.into_make_service_with_connect_info::(); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + tracing::info!("Server shutting down, closing database pool"); + db.close().await; Ok(()) } diff --git a/crates/server/src/routes/admin.rs b/crates/server/src/routes/admin.rs new file mode 100644 index 0000000..dccc7e1 --- /dev/null +++ b/crates/server/src/routes/admin.rs @@ -0,0 +1,125 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + routing::get, +}; +use fc_common::Validate; +use fc_common::models::{CreateRemoteBuilder, RemoteBuilder, SystemStatus, UpdateRemoteBuilder}; +use uuid::Uuid; + +use crate::auth_middleware::RequireAdmin; +use crate::error::ApiError; +use crate::state::AppState; + +async fn list_builders( + State(state): State, +) -> Result>, ApiError> { + let builders = fc_common::repo::remote_builders::list(&state.pool) + .await + .map_err(ApiError)?; + Ok(Json(builders)) +} + +async fn get_builder( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let builder = fc_common::repo::remote_builders::get(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(builder)) +} + +async fn create_builder( + _auth: RequireAdmin, + State(state): State, + Json(input): Json, +) -> Result, ApiError> { + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let builder = fc_common::repo::remote_builders::create(&state.pool, input) + .await + .map_err(ApiError)?; + Ok(Json(builder)) +} + +async fn update_builder( + _auth: RequireAdmin, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, ApiError> { + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let builder = fc_common::repo::remote_builders::update(&state.pool, id, input) + .await + .map_err(ApiError)?; + Ok(Json(builder)) +} + +async fn delete_builder( + _auth: RequireAdmin, + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + fc_common::repo::remote_builders::delete(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(serde_json::json!({"deleted": true}))) +} + +async fn system_status( + _auth: RequireAdmin, + State(state): State, +) -> Result, ApiError> { + let pool = &state.pool; + + let projects: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects") + .fetch_one(pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + let jobsets: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobsets") + .fetch_one(pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + let evaluations: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations") + .fetch_one(pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + + let stats = fc_common::repo::builds::get_stats(pool) + .await + .map_err(ApiError)?; + let builders = fc_common::repo::remote_builders::count(pool) + .await + .map_err(ApiError)?; + + let channels: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM channels") + .fetch_one(pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + + Ok(Json(SystemStatus { + projects_count: projects.0, + jobsets_count: jobsets.0, + evaluations_count: evaluations.0, + builds_pending: stats.pending_builds.unwrap_or(0), + builds_running: stats.running_builds.unwrap_or(0), + builds_completed: stats.completed_builds.unwrap_or(0), + builds_failed: stats.failed_builds.unwrap_or(0), + remote_builders: builders, + channels_count: channels.0, + })) +} + +pub fn router() -> Router { + Router::new() + .route("/admin/builders", get(list_builders).post(create_builder)) + .route( + "/admin/builders/{id}", + get(get_builder).put(update_builder).delete(delete_builder), + ) + .route("/admin/system", get(system_status)) +} diff --git a/crates/server/src/routes/auth.rs b/crates/server/src/routes/auth.rs new file mode 100644 index 0000000..d1ac7e4 --- /dev/null +++ b/crates/server/src/routes/auth.rs @@ -0,0 +1,98 @@ +use axum::{Json, Router, extract::State, routing::get}; +use fc_common::repo; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::auth_middleware::RequireAdmin; +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateApiKeyRequest { + pub name: String, + pub role: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateApiKeyResponse { + pub id: Uuid, + pub name: String, + pub key: String, + pub role: String, +} + +#[derive(Debug, Serialize)] +pub struct ApiKeyInfo { + pub id: Uuid, + pub name: String, + pub role: String, + pub created_at: chrono::DateTime, + pub last_used_at: Option>, +} + +pub fn hash_api_key(key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(key.as_bytes()); + hex::encode(hasher.finalize()) +} + +async fn create_api_key( + _auth: RequireAdmin, + State(state): State, + Json(input): Json, +) -> Result, ApiError> { + let role = input.role.unwrap_or_else(|| "read-only".to_string()); + + // Generate a random 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) + .await + .map_err(ApiError)?; + + Ok(Json(CreateApiKeyResponse { + id: api_key.id, + name: api_key.name, + key, // Only returned once at creation time + role: api_key.role, + })) +} + +async fn list_api_keys( + _auth: RequireAdmin, + State(state): State, +) -> Result>, ApiError> { + let keys = repo::api_keys::list(&state.pool).await.map_err(ApiError)?; + + let infos: Vec = keys + .into_iter() + .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(); + + Ok(Json(infos)) +} + +async fn delete_api_key( + _auth: RequireAdmin, + State(state): State, + axum::extract::Path(id): axum::extract::Path, +) -> Result, ApiError> { + repo::api_keys::delete(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(serde_json::json!({ "deleted": true }))) +} + +pub fn router() -> Router { + Router::new() + .route("/api-keys", get(list_api_keys).post(create_api_key)) + .route("/api-keys/{id}", axum::routing::delete(delete_api_key)) +} diff --git a/crates/server/src/routes/badges.rs b/crates/server/src/routes/badges.rs new file mode 100644 index 0000000..f619c2a --- /dev/null +++ b/crates/server/src/routes/badges.rs @@ -0,0 +1,171 @@ +use axum::{ + Router, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; + +use crate::error::ApiError; +use crate::state::AppState; + +async fn build_badge( + State(state): State, + Path((project_name, jobset_name, job_name)): Path<(String, String, String)>, +) -> Result { + // Find the project + 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) + .await + .map_err(ApiError)?; + + let jobset = jobsets.iter().find(|j| j.name == jobset_name); + let jobset = match jobset { + Some(j) => j, + None => { + return Ok(shield_svg("build", "not found", "#9f9f9f").into_response()); + } + }; + + // Get latest evaluation + let eval = fc_common::repo::evaluations::get_latest(&state.pool, jobset.id) + .await + .map_err(ApiError)?; + + let eval = match eval { + Some(e) => e, + None => { + return Ok(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) + .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 { + 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(( + StatusCode::OK, + [ + ("content-type", "image/svg+xml"), + ("cache-control", "no-cache, no-store, must-revalidate"), + ], + shield_svg("build", label, color), + ) + .into_response()) +} + +/// Latest successful build redirect +async fn latest_build( + State(state): State, + Path((project_name, jobset_name, job_name)): Path<(String, String, String)>, +) -> Result { + 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) + .await + .map_err(ApiError)?; + + let jobset = jobsets.iter().find(|j| j.name == jobset_name); + let jobset = match jobset { + Some(j) => j, + None => { + return Ok((StatusCode::NOT_FOUND, "Jobset not found").into_response()); + } + }; + + let eval = fc_common::repo::evaluations::get_latest(&state.pool, jobset.id) + .await + .map_err(ApiError)?; + + let eval = match eval { + Some(e) => e, + None => { + return Ok((StatusCode::NOT_FOUND, "No evaluations found").into_response()); + } + }; + + let 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); + match build { + Some(b) => Ok(axum::Json(b.clone()).into_response()), + None => Ok((StatusCode::NOT_FOUND, "Build not found").into_response()), + } +} + +fn shield_svg(subject: &str, status: &str, color: &str) -> String { + let subject_width = subject.len() * 7 + 10; + let status_width = status.len() * 7 + 10; + let total_width = subject_width + status_width; + let subject_x = subject_width / 2; + let status_x = subject_width + status_width / 2; + + let mut svg = String::new(); + svg.push_str(&format!( + "\n" + )); + svg.push_str(" \n"); + svg.push_str(" \n"); + svg.push_str(" \n"); + svg.push_str(" \n"); + svg.push_str(" \n"); + svg.push_str(&format!( + " \n" + )); + svg.push_str(" \n"); + svg.push_str(" \n"); + svg.push_str(&format!( + " \n" + )); + svg.push_str(&format!( + " \n" + )); + svg.push_str(&format!( + " \n" + )); + svg.push_str(" \n"); + svg.push_str(" \n"); + svg.push_str(&format!( + " {subject}\n" + )); + svg.push_str(&format!( + " {subject}\n" + )); + svg.push_str(&format!( + " {status}\n" + )); + svg.push_str(&format!( + " {status}\n" + )); + svg.push_str(" \n"); + svg.push_str(""); + svg +} + +pub fn router() -> Router { + Router::new() + .route("/job/{project}/{jobset}/{job}/shield", get(build_badge)) + .route("/job/{project}/{jobset}/{job}/latest", get(latest_build)) +} diff --git a/crates/server/src/routes/builds.rs b/crates/server/src/routes/builds.rs new file mode 100644 index 0000000..3fa171d --- /dev/null +++ b/crates/server/src/routes/builds.rs @@ -0,0 +1,334 @@ +use axum::{ + Json, Router, + body::Body, + extract::{Path, Query, State}, + http::{Extensions, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use fc_common::{ + Build, BuildProduct, BuildStatus, BuildStep, CreateBuild, PaginatedResponse, PaginationParams, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth_middleware::RequireRoles; +use crate::error::ApiError; +use crate::state::AppState; + +fn check_role(extensions: &Extensions, allowed: &[&str]) -> Result<(), ApiError> { + RequireRoles::check(extensions, allowed) + .map(|_| ()) + .map_err(|s| { + ApiError(if s == StatusCode::FORBIDDEN { + fc_common::CiError::Forbidden("Insufficient permissions".to_string()) + } else { + fc_common::CiError::Unauthorized("Authentication required".to_string()) + }) + }) +} + +#[derive(Debug, Deserialize)] +struct ListBuildsParams { + evaluation_id: Option, + status: Option, + system: Option, + job_name: Option, + limit: Option, + offset: Option, +} + +async fn list_builds( + State(state): State, + Query(params): Query, +) -> Result>, ApiError> { + let pagination = PaginationParams { + limit: params.limit, + offset: params.offset, + }; + let limit = pagination.limit(); + let offset = pagination.offset(); + let items = fc_common::repo::builds::list_filtered( + &state.pool, + params.evaluation_id, + params.status.as_deref(), + params.system.as_deref(), + params.job_name.as_deref(), + limit, + offset, + ) + .await + .map_err(ApiError)?; + let total = fc_common::repo::builds::count_filtered( + &state.pool, + params.evaluation_id, + params.status.as_deref(), + params.system.as_deref(), + params.job_name.as_deref(), + ) + .await + .map_err(ApiError)?; + Ok(Json(PaginatedResponse { + items, + total, + limit, + offset, + })) +} + +async fn get_build( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let build = fc_common::repo::builds::get(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(build)) +} + +async fn cancel_build( + extensions: Extensions, + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + check_role(&extensions, &["cancel-build"])?; + let cancelled = fc_common::repo::builds::cancel_cascade(&state.pool, id) + .await + .map_err(ApiError)?; + if cancelled.is_empty() { + return Err(ApiError(fc_common::CiError::NotFound( + "Build not found or not in a cancellable state".to_string(), + ))); + } + Ok(Json(cancelled)) +} + +async fn list_build_steps( + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + let steps = fc_common::repo::build_steps::list_for_build(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(steps)) +} + +async fn list_build_products( + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + let products = fc_common::repo::build_products::list_for_build(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(products)) +} + +async fn build_stats( + State(state): State, +) -> Result, ApiError> { + let stats = fc_common::repo::builds::get_stats(&state.pool) + .await + .map_err(ApiError)?; + Ok(Json(stats)) +} + +async fn recent_builds(State(state): State) -> Result>, ApiError> { + let builds = fc_common::repo::builds::list_recent(&state.pool, 20) + .await + .map_err(ApiError)?; + Ok(Json(builds)) +} + +async fn list_project_builds( + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + let builds = fc_common::repo::builds::list_for_project(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(builds)) +} + +async fn restart_build( + extensions: Extensions, + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + check_role(&extensions, &["restart-jobs"])?; + let original = fc_common::repo::builds::get(&state.pool, id) + .await + .map_err(ApiError)?; + + // Can only restart completed or failed builds + if original.status != BuildStatus::Failed + && original.status != BuildStatus::Completed + && original.status != BuildStatus::Cancelled + { + return Err(ApiError(fc_common::CiError::Validation( + "Can only restart failed, completed, or cancelled builds".to_string(), + ))); + } + + // Create a new build with the same parameters + let new_build = fc_common::repo::builds::create( + &state.pool, + CreateBuild { + evaluation_id: original.evaluation_id, + job_name: original.job_name.clone(), + drv_path: original.drv_path.clone(), + system: original.system.clone(), + outputs: original.outputs.clone(), + is_aggregate: Some(original.is_aggregate), + constituents: original.constituents.clone(), + }, + ) + .await + .map_err(ApiError)?; + + tracing::info!( + original_id = %id, + new_id = %new_build.id, + job = %original.job_name, + "Build restarted" + ); + + Ok(Json(new_build)) +} + +async fn bump_build( + extensions: Extensions, + State(state): State, + Path(id): Path, +) -> Result, 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 *", + ) + .bind(id) + .fetch_optional(&state.pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))? + .ok_or_else(|| { + ApiError(fc_common::CiError::Validation( + "Build not found or not in pending state".to_string(), + )) + })?; + + Ok(Json(build)) +} + +async fn download_build_product( + State(state): State, + Path((build_id, product_id)): Path<(Uuid, Uuid)>, +) -> Result { + // Verify build exists + let _build = fc_common::repo::builds::get(&state.pool, build_id) + .await + .map_err(ApiError)?; + + let product = fc_common::repo::build_products::get(&state.pool, product_id) + .await + .map_err(ApiError)?; + + if product.build_id != build_id { + return Err(ApiError(fc_common::CiError::NotFound( + "Product does not belong to this build".to_string(), + ))); + } + + if !fc_common::validate::is_valid_store_path(&product.path) { + return Err(ApiError(fc_common::CiError::Validation( + "Invalid store path".to_string(), + ))); + } + + if product.is_directory { + // Stream as NAR using nix store dump-path + let child = tokio::process::Command::new("nix") + .args(["store", "dump-path", &product.path]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn(); + + let mut child = match child { + Ok(c) => c, + Err(e) => { + return Err(ApiError(fc_common::CiError::Build(format!( + "Failed to dump path: {e}" + )))); + } + }; + + let stdout = match child.stdout.take() { + Some(s) => s, + None => { + return Err(ApiError(fc_common::CiError::Build( + "Failed to capture output".to_string(), + ))); + } + }; + + let stream = tokio_util::io::ReaderStream::new(stdout); + let body = Body::from_stream(stream); + + let filename = product.path.rsplit('/').next().unwrap_or(&product.name); + + Ok(( + StatusCode::OK, + [ + ("content-type", "application/x-nix-nar"), + ( + "content-disposition", + &format!("attachment; filename=\"{filename}.nar\""), + ), + ], + body, + ) + .into_response()) + } else { + // Serve file directly + let file = tokio::fs::File::open(&product.path) + .await + .map_err(|e| ApiError(fc_common::CiError::Io(e)))?; + + let stream = tokio_util::io::ReaderStream::new(file); + let body = Body::from_stream(stream); + + let content_type = product + .content_type + .as_deref() + .unwrap_or("application/octet-stream"); + let filename = product.path.rsplit('/').next().unwrap_or(&product.name); + + Ok(( + StatusCode::OK, + [ + ("content-type", content_type), + ( + "content-disposition", + &format!("attachment; filename=\"{filename}\""), + ), + ], + body, + ) + .into_response()) + } +} + +pub fn router() -> Router { + Router::new() + .route("/builds", get(list_builds)) + .route("/builds/stats", get(build_stats)) + .route("/builds/recent", get(recent_builds)) + .route("/builds/{id}", get(get_build)) + .route("/builds/{id}/cancel", post(cancel_build)) + .route("/builds/{id}/restart", post(restart_build)) + .route("/builds/{id}/bump", post(bump_build)) + .route("/builds/{id}/steps", get(list_build_steps)) + .route("/builds/{id}/products", get(list_build_products)) + .route( + "/builds/{build_id}/products/{product_id}/download", + get(download_build_product), + ) + .route("/projects/{id}/builds", get(list_project_builds)) +} diff --git a/crates/server/src/routes/cache.rs b/crates/server/src/routes/cache.rs new file mode 100644 index 0000000..a5194df --- /dev/null +++ b/crates/server/src/routes/cache.rs @@ -0,0 +1,367 @@ +use axum::{ + Router, + body::Body, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use tokio::process::Command; + +use crate::error::ApiError; +use crate::state::AppState; + +/// Serve NARInfo for a store path hash. +/// GET /nix-cache/{hash}.narinfo +async fn narinfo( + State(state): State, + Path(hash): Path, +) -> Result { + if !state.config.cache.enabled { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + // Strip .narinfo suffix if present + let hash = hash.strip_suffix(".narinfo").unwrap_or(&hash); + + if !fc_common::validate::is_valid_nix_hash(hash) { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + // Look up the store path from build_products by matching the hash prefix + let product = sqlx::query_as::<_, fc_common::models::BuildProduct>( + "SELECT * FROM build_products WHERE path LIKE $1 LIMIT 1", + ) + .bind(format!("/nix/store/{hash}-%")) + .fetch_optional(&state.pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + + let product = match product { + Some(p) => p, + None => return Ok(StatusCode::NOT_FOUND.into_response()), + }; + + if !fc_common::validate::is_valid_store_path(&product.path) { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + // Get narinfo from nix path-info + let output = Command::new("nix") + .args(["path-info", "--json", &product.path]) + .output() + .await; + + let output = match output { + Ok(o) if o.status.success() => o, + _ => return Ok(StatusCode::NOT_FOUND.into_response()), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = match serde_json::from_str(&stdout) { + Ok(v) => v, + Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()), + }; + + let entry = match parsed.as_array().and_then(|a| a.first()) { + Some(e) => e, + None => return Ok(StatusCode::NOT_FOUND.into_response()), + }; + + let nar_hash = entry.get("narHash").and_then(|v| v.as_str()).unwrap_or(""); + let nar_size = entry.get("narSize").and_then(|v| v.as_u64()).unwrap_or(0); + let store_path = entry + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or(&product.path); + + let refs: Vec<&str> = entry + .get("references") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|r| r.as_str()) + .map(|s| s.strip_prefix("/nix/store/").unwrap_or(s)) + .collect() + }) + .unwrap_or_default(); + + // Extract deriver + let deriver = entry + .get("deriver") + .and_then(|v| v.as_str()) + .map(|d| d.strip_prefix("/nix/store/").unwrap_or(d)); + + // Extract content-addressable hash + let ca = entry.get("ca").and_then(|v| v.as_str()); + + 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", + store_path = store_path, + hash = hash, + file_hash = file_hash, + nar_size = nar_size, + nar_hash = nar_hash, + refs = refs.join(" "), + ); + + if let Some(deriver) = deriver { + narinfo_text.push_str(&format!("Deriver: {deriver}\n")); + } + if let Some(ca) = ca { + narinfo_text.push_str(&format!("CA: {ca}\n")); + } + + // Optionally sign if secret key is configured + 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 { + narinfo_text + } + } else { + narinfo_text + }; + + Ok(( + StatusCode::OK, + [("content-type", "text/x-nix-narinfo")], + narinfo_text, + ) + .into_response()) +} + +/// Sign narinfo using nix store sign command +async fn sign_narinfo(narinfo: &str, key_file: &std::path::Path) -> String { + let store_path = narinfo + .lines() + .find(|l| l.starts_with("StorePath: ")) + .and_then(|l| l.strip_prefix("StorePath: ")); + + let store_path = match store_path { + Some(p) => p, + None => return narinfo.to_string(), + }; + + let output = Command::new("nix") + .args([ + "store", + "sign", + "--key-file", + &key_file.to_string_lossy(), + store_path, + ]) + .output() + .await; + + match output { + Ok(o) if o.status.success() => { + let re_output = Command::new("nix") + .args(["path-info", "--json", store_path]) + .output() + .await; + + if let Ok(o) = re_output { + if let Ok(parsed) = serde_json::from_slice::(&o.stdout) { + if let Some(sigs) = parsed + .as_array() + .and_then(|a| a.first()) + .and_then(|e| e.get("signatures")) + .and_then(|v| v.as_array()) + { + let sig_lines: Vec = sigs + .iter() + .filter_map(|s| s.as_str()) + .map(|s| format!("Sig: {s}")) + .collect(); + if !sig_lines.is_empty() { + return format!("{narinfo}{}\n", sig_lines.join("\n")); + } + } + } + } + narinfo.to_string() + } + _ => narinfo.to_string(), + } +} + +/// Serve a compressed NAR file for a store path. +/// GET /nix-cache/nar/{hash}.nar.zst +async fn serve_nar_zst( + State(state): State, + Path(hash): Path, +) -> Result { + if !state.config.cache.enabled { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + let hash = hash + .strip_suffix(".nar.zst") + .or_else(|| hash.strip_suffix(".nar")) + .unwrap_or(&hash); + + if !fc_common::validate::is_valid_nix_hash(hash) { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + let product = sqlx::query_as::<_, fc_common::models::BuildProduct>( + "SELECT * FROM build_products WHERE path LIKE $1 LIMIT 1", + ) + .bind(format!("/nix/store/{hash}-%")) + .fetch_optional(&state.pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + + let product = match product { + Some(p) => p, + None => return Ok(StatusCode::NOT_FOUND.into_response()), + }; + + if !fc_common::validate::is_valid_store_path(&product.path) { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + // Use two piped processes instead of sh -c to prevent command injection + let mut nix_child = std::process::Command::new("nix") + .args(["store", "dump-path", &product.path]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|_| { + ApiError(fc_common::CiError::Build( + "Failed to start nix store dump-path".to_string(), + )) + })?; + + let nix_stdout = match nix_child.stdout.take() { + Some(s) => s, + None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()), + }; + + let mut zstd_child = Command::new("zstd") + .arg("-c") + .stdin(nix_stdout) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|_| { + ApiError(fc_common::CiError::Build( + "Failed to start zstd compression".to_string(), + )) + })?; + + let zstd_stdout = match zstd_child.stdout.take() { + Some(s) => s, + None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()), + }; + + 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()) +} + +/// Serve an uncompressed NAR file for a store path (legacy). +/// GET /nix-cache/nar/{hash}.nar +async fn serve_nar( + State(state): State, + Path(hash): Path, +) -> Result { + if !state.config.cache.enabled { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + let hash = hash.strip_suffix(".nar").unwrap_or(&hash); + + if !fc_common::validate::is_valid_nix_hash(hash) { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + let product = sqlx::query_as::<_, fc_common::models::BuildProduct>( + "SELECT * FROM build_products WHERE path LIKE $1 LIMIT 1", + ) + .bind(format!("/nix/store/{hash}-%")) + .fetch_optional(&state.pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + + let product = match product { + Some(p) => p, + None => return Ok(StatusCode::NOT_FOUND.into_response()), + }; + + if !fc_common::validate::is_valid_store_path(&product.path) { + return Ok(StatusCode::NOT_FOUND.into_response()); + } + + let child = Command::new("nix") + .args(["store", "dump-path", &product.path]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn(); + + let mut child = match child { + Ok(c) => c, + Err(_) => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()), + }; + + let stdout = match child.stdout.take() { + Some(s) => s, + None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()), + }; + + let stream = tokio_util::io::ReaderStream::new(stdout); + let body = Body::from_stream(stream); + + Ok(( + StatusCode::OK, + [("content-type", "application/x-nix-nar")], + body, + ) + .into_response()) +} + +/// Combined NAR handler — dispatches to zstd or plain based on suffix. +/// GET /nix-cache/nar/{hash} where hash includes .nar.zst or .nar suffix +async fn serve_nar_combined( + state: State, + path: Path, +) -> Result { + let hash_raw = path.0.clone(); + if hash_raw.ends_with(".nar.zst") { + serve_nar_zst(state, path).await + } else if hash_raw.ends_with(".nar") { + serve_nar(state, path).await + } else { + Ok(StatusCode::NOT_FOUND.into_response()) + } +} + +/// Nix binary cache info endpoint. +/// GET /nix-cache/nix-cache-info +async fn cache_info(State(state): State) -> Response { + if !state.config.cache.enabled { + return StatusCode::NOT_FOUND.into_response(); + } + + let info = "StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 30\n"; + + (StatusCode::OK, [("content-type", "text/plain")], info).into_response() +} + +pub fn router() -> Router { + Router::new() + .route("/nix-cache/nix-cache-info", get(cache_info)) + .route("/nix-cache/{hash}", get(narinfo)) + .route("/nix-cache/nar/{hash}", get(serve_nar_combined)) +} diff --git a/crates/server/src/routes/channels.rs b/crates/server/src/routes/channels.rs new file mode 100644 index 0000000..f798233 --- /dev/null +++ b/crates/server/src/routes/channels.rs @@ -0,0 +1,89 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + routing::{get, post}, +}; +use fc_common::Validate; +use fc_common::models::{Channel, CreateChannel}; +use uuid::Uuid; + +use crate::auth_middleware::RequireAdmin; +use crate::error::ApiError; +use crate::state::AppState; + +async fn list_channels(State(state): State) -> Result>, ApiError> { + let channels = fc_common::repo::channels::list_all(&state.pool) + .await + .map_err(ApiError)?; + Ok(Json(channels)) +} + +async fn list_project_channels( + State(state): State, + Path(project_id): Path, +) -> Result>, ApiError> { + let channels = fc_common::repo::channels::list_for_project(&state.pool, project_id) + .await + .map_err(ApiError)?; + Ok(Json(channels)) +} + +async fn get_channel( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let channel = fc_common::repo::channels::get(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(channel)) +} + +async fn create_channel( + _auth: RequireAdmin, + State(state): State, + Json(input): Json, +) -> Result, ApiError> { + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let channel = fc_common::repo::channels::create(&state.pool, input) + .await + .map_err(ApiError)?; + Ok(Json(channel)) +} + +async fn delete_channel( + _auth: RequireAdmin, + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + fc_common::repo::channels::delete(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(serde_json::json!({"deleted": true}))) +} + +async fn promote_channel( + _auth: RequireAdmin, + State(state): State, + Path((channel_id, eval_id)): Path<(Uuid, Uuid)>, +) -> Result, ApiError> { + let channel = fc_common::repo::channels::promote(&state.pool, channel_id, eval_id) + .await + .map_err(ApiError)?; + Ok(Json(channel)) +} + +pub fn router() -> Router { + Router::new() + .route("/channels", get(list_channels).post(create_channel)) + .route("/channels/{id}", get(get_channel).delete(delete_channel)) + .route( + "/channels/{channel_id}/promote/{eval_id}", + post(promote_channel), + ) + .route( + "/projects/{project_id}/channels", + get(list_project_channels), + ) +} diff --git a/crates/server/src/routes/dashboard.rs b/crates/server/src/routes/dashboard.rs new file mode 100644 index 0000000..99283cd --- /dev/null +++ b/crates/server/src/routes/dashboard.rs @@ -0,0 +1,978 @@ +use askama::Template; +use axum::{ + Form, Router, + extract::{Path, Query, State}, + http::Extensions, + response::{Html, IntoResponse, Redirect, Response}, + routing::get, +}; +use fc_common::models::*; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::state::AppState; + +// --- View models (pre-formatted for templates) --- + +struct BuildView { + id: Uuid, + job_name: String, + status_text: String, + status_class: String, + system: String, + created_at: String, + started_at: String, + completed_at: String, + duration: String, + priority: i32, + is_aggregate: bool, + signed: bool, + drv_path: String, + output_path: String, + error_message: String, + log_url: String, +} + +struct EvalView { + id: Uuid, + commit_hash: String, + commit_short: String, + status_text: String, + status_class: String, + time: String, + error_message: Option, + jobset_name: String, + project_name: String, +} + +struct EvalSummaryView { + id: Uuid, + commit_short: String, + status_text: String, + status_class: String, + time: String, + succeeded: i64, + failed: i64, + pending: i64, +} + +struct ProjectSummaryView { + id: Uuid, + name: String, + jobset_count: i64, + last_eval_status: String, + last_eval_class: String, + last_eval_time: String, +} + +struct ApiKeyView { + id: Uuid, + name: String, + role: String, + created_at: String, + last_used_at: String, +} + +fn format_duration( + started: Option<&chrono::DateTime>, + completed: Option<&chrono::DateTime>, +) -> String { + match (started, completed) { + (Some(s), Some(c)) => { + let secs = (*c - *s).num_seconds(); + if secs < 0 { + return String::new(); + } + let mins = secs / 60; + let rem = secs % 60; + if mins > 0 { + format!("{mins}m {rem}s") + } else { + format!("{rem}s") + } + } + _ => String::new(), + } +} + +fn build_view(b: &Build) -> BuildView { + let (text, class) = status_badge(&b.status); + BuildView { + id: b.id, + job_name: b.job_name.clone(), + status_text: text, + status_class: class, + system: b.system.clone().unwrap_or_else(|| "-".to_string()), + created_at: b.created_at.format("%Y-%m-%d %H:%M").to_string(), + started_at: b + .started_at + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_default(), + completed_at: b + .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()), + priority: b.priority, + is_aggregate: b.is_aggregate, + signed: b.signed, + drv_path: b.drv_path.clone(), + output_path: b.build_output_path.clone().unwrap_or_default(), + error_message: b.error_message.clone().unwrap_or_default(), + log_url: b.log_url.clone().unwrap_or_default(), + } +} + +fn eval_view(e: &Evaluation) -> EvalView { + let (text, class) = eval_badge(&e.status); + let short = if e.commit_hash.len() > 12 { + e.commit_hash[..12].to_string() + } else { + e.commit_hash.clone() + }; + EvalView { + id: e.id, + commit_hash: e.commit_hash.clone(), + commit_short: short, + status_text: text, + status_class: class, + time: e.evaluation_time.format("%Y-%m-%d %H:%M").to_string(), + error_message: e.error_message.clone(), + jobset_name: String::new(), + project_name: String::new(), + } +} + +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(); + v +} + +fn status_badge(s: &BuildStatus) -> (String, String) { + match s { + BuildStatus::Completed => ("Completed".into(), "completed".into()), + BuildStatus::Failed => ("Failed".into(), "failed".into()), + BuildStatus::Running => ("Running".into(), "running".into()), + BuildStatus::Pending => ("Pending".into(), "pending".into()), + BuildStatus::Cancelled => ("Cancelled".into(), "cancelled".into()), + } +} + +fn eval_badge(s: &EvaluationStatus) -> (String, String) { + match s { + EvaluationStatus::Completed => ("Completed".into(), "completed".into()), + EvaluationStatus::Failed => ("Failed".into(), "failed".into()), + EvaluationStatus::Running => ("Running".into(), "running".into()), + EvaluationStatus::Pending => ("Pending".into(), "pending".into()), + } +} + +fn is_admin(extensions: &Extensions) -> bool { + extensions + .get::() + .map(|k| k.role == "admin") + .unwrap_or(false) +} + +fn auth_name(extensions: &Extensions) -> String { + extensions + .get::() + .map(|k| k.name.clone()) + .unwrap_or_default() +} + +// --- Templates --- + +#[derive(Template)] +#[template(path = "home.html")] +struct HomeTemplate { + total_builds: i64, + completed_builds: i64, + failed_builds: i64, + running_builds: i64, + pending_builds: i64, + recent_builds: Vec, + recent_evals: Vec, + projects: Vec, + is_admin: bool, + auth_name: String, +} + +#[derive(Template)] +#[template(path = "projects.html")] +struct ProjectsTemplate { + projects: Vec, + limit: i64, + has_prev: bool, + has_next: bool, + prev_offset: i64, + next_offset: i64, + page: i64, + total_pages: i64, + is_admin: bool, + auth_name: String, +} + +#[derive(Template)] +#[template(path = "project.html")] +struct ProjectTemplate { + project: Project, + jobsets: Vec, + recent_evals: Vec, + is_admin: bool, + auth_name: String, +} + +#[derive(Template)] +#[template(path = "jobset.html")] +struct JobsetTemplate { + project: Project, + jobset: Jobset, + eval_summaries: Vec, +} + +#[derive(Template)] +#[template(path = "evaluations.html")] +struct EvaluationsTemplate { + evals: Vec, + limit: i64, + has_prev: bool, + has_next: bool, + prev_offset: i64, + next_offset: i64, + page: i64, + total_pages: i64, +} + +#[derive(Template)] +#[template(path = "evaluation.html")] +struct EvaluationTemplate { + eval: EvalView, + builds: Vec, + project_name: String, + project_id: Uuid, + jobset_name: String, + jobset_id: Uuid, + succeeded_count: i64, + failed_count: i64, + running_count: i64, + pending_count: i64, +} + +#[derive(Template)] +#[template(path = "builds.html")] +struct BuildsTemplate { + builds: Vec, + limit: i64, + has_prev: bool, + has_next: bool, + prev_offset: i64, + next_offset: i64, + page: i64, + total_pages: i64, + filter_status: String, + filter_system: String, + filter_job: String, +} + +#[derive(Template)] +#[template(path = "build.html")] +struct BuildTemplate { + build: BuildView, + steps: Vec, + products: Vec, + eval_id: Uuid, + eval_commit_short: String, + jobset_id: Uuid, + jobset_name: String, + project_id: Uuid, + project_name: String, +} + +#[derive(Template)] +#[template(path = "queue.html")] +struct QueueTemplate { + pending_builds: Vec, + running_builds: Vec, + pending_count: i64, + running_count: i64, +} + +#[derive(Template)] +#[template(path = "channels.html")] +struct ChannelsTemplate { + channels: Vec, +} + +#[derive(Template)] +#[template(path = "admin.html")] +struct AdminTemplate { + status: SystemStatus, + builders: Vec, + api_keys: Vec, + is_admin: bool, + auth_name: String, +} + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginTemplate { + error: Option, +} + +// --- Handlers --- + +async fn home(State(state): State, extensions: Extensions) -> Html { + 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) + .await + .unwrap_or_default(); + + // Fetch project summaries + let all_projects = fc_common::repo::projects::list(&state.pool, 10, 0) + .await + .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) + .await + .unwrap_or(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 = None; + for js in &jobsets { + 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() { + if last_eval + .as_ref() + .map_or(true, |le| e.evaluation_time > le.evaluation_time) + { + last_eval = Some(e); + } + } + } + let (status, class, time) = match &last_eval { + 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 { + id: p.id, + name: p.name.clone(), + jobset_count, + last_eval_status: status, + last_eval_class: class, + last_eval_time: time, + }); + } + + let tmpl = HomeTemplate { + total_builds: stats.total_builds.unwrap_or(0), + completed_builds: stats.completed_builds.unwrap_or(0), + failed_builds: stats.failed_builds.unwrap_or(0), + running_builds: stats.running_builds.unwrap_or(0), + pending_builds: stats.pending_builds.unwrap_or(0), + recent_builds: builds.iter().map(build_view).collect(), + recent_evals: evals.iter().map(eval_view).collect(), + projects: project_summaries, + is_admin: is_admin(&extensions), + auth_name: auth_name(&extensions), + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +#[derive(serde::Deserialize)] +struct PageParams { + limit: Option, + offset: Option, +} + +async fn projects_page( + State(state): State, + Query(params): Query, + extensions: Extensions, +) -> Html { + 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::projects::list(&state.pool, limit, offset) + .await + .unwrap_or_default(); + let total = fc_common::repo::projects::count(&state.pool) + .await + .unwrap_or(0); + + let total_pages = (total + limit - 1) / limit.max(1); + let page = offset / limit.max(1) + 1; + let tmpl = ProjectsTemplate { + projects: items, + limit, + has_prev: offset > 0, + has_next: offset + limit < total, + prev_offset: (offset - limit).max(0), + next_offset: offset + limit, + page, + total_pages, + is_admin: is_admin(&extensions), + auth_name: auth_name(&extensions), + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +async fn project_page( + State(state): State, + Path(id): Path, + extensions: Extensions, +) -> Html { + let project = match fc_common::repo::projects::get(&state.pool, id).await { + Ok(p) => p, + Err(_) => return Html("Project not found".to_string()), + }; + let 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) + .await + .unwrap_or_default(); + evals.append(&mut js_evals); + } + evals.sort_by(|a, b| b.evaluation_time.cmp(&a.evaluation_time)); + evals.truncate(10); + + let tmpl = ProjectTemplate { + project, + jobsets, + recent_evals: evals.iter().map(eval_view).collect(), + is_admin: is_admin(&extensions), + auth_name: auth_name(&extensions), + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +async fn jobset_page(State(state): State, Path(id): Path) -> Html { + let jobset = match fc_common::repo::jobsets::get(&state.pool, id).await { + Ok(j) => j, + Err(_) => return Html("Jobset not found".to_string()), + }; + let 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) + .await + .unwrap_or_default(); + + let mut summaries = Vec::new(); + for e in &evals { + let (text, class) = eval_badge(&e.status); + let short = if e.commit_hash.len() > 12 { + e.commit_hash[..12].to_string() + } else { + e.commit_hash.clone() + }; + let succeeded = fc_common::repo::builds::count_filtered( + &state.pool, + Some(e.id), + Some("completed"), + None, + None, + ) + .await + .unwrap_or(0); + let failed = fc_common::repo::builds::count_filtered( + &state.pool, + Some(e.id), + Some("failed"), + None, + None, + ) + .await + .unwrap_or(0); + let pending = fc_common::repo::builds::count_filtered( + &state.pool, + Some(e.id), + Some("pending"), + None, + None, + ) + .await + .unwrap_or(0); + + summaries.push(EvalSummaryView { + id: e.id, + commit_short: short, + status_text: text, + status_class: class, + time: e.evaluation_time.format("%Y-%m-%d %H:%M").to_string(), + succeeded, + failed, + pending, + }); + } + + let tmpl = JobsetTemplate { + project, + jobset, + eval_summaries: summaries, + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +async fn evaluations_page( + State(state): State, + Query(params): Query, +) -> Html { + 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) + .await + .unwrap_or_default(); + 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 { + Ok(js) => { + 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)); + } + + let total_pages = (total + limit - 1) / limit.max(1); + let page = offset / limit.max(1) + 1; + let tmpl = EvaluationsTemplate { + evals: enriched, + limit, + has_prev: offset > 0, + has_next: offset + limit < total, + prev_offset: (offset - limit).max(0), + next_offset: offset + limit, + page, + total_pages, + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +async fn evaluation_page(State(state): State, Path(id): Path) -> Html { + let eval = match fc_common::repo::evaluations::get(&state.pool, id).await { + Ok(e) => e, + Err(_) => return Html("Evaluation not found".to_string()), + }; + + let jobset = match fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await { + Ok(j) => j, + Err(_) => return Html("Jobset not found".to_string()), + }; + let project = match fc_common::repo::projects::get(&state.pool, jobset.project_id).await { + Ok(p) => p, + Err(_) => return Html("Project not found".to_string()), + }; + + let builds = + fc_common::repo::builds::list_filtered(&state.pool, Some(id), None, None, None, 200, 0) + .await + .unwrap_or_default(); + + let succeeded = fc_common::repo::builds::count_filtered( + &state.pool, + Some(id), + Some("completed"), + None, + None, + ) + .await + .unwrap_or(0); + 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) + .await + .unwrap_or(0); + let pending = + fc_common::repo::builds::count_filtered(&state.pool, Some(id), Some("pending"), None, None) + .await + .unwrap_or(0); + + let tmpl = EvaluationTemplate { + eval: eval_view(&eval), + builds: builds.iter().map(build_view).collect(), + project_name: project.name, + project_id: project.id, + jobset_name: jobset.name, + jobset_id: jobset.id, + succeeded_count: succeeded, + failed_count: failed, + running_count: running, + pending_count: pending, + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +#[derive(serde::Deserialize)] +struct BuildFilterParams { + status: Option, + system: Option, + job_name: Option, + limit: Option, + offset: Option, +} + +async fn builds_page( + State(state): State, + Query(params): Query, +) -> Html { + 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::builds::list_filtered( + &state.pool, + None, + params.status.as_deref(), + params.system.as_deref(), + params.job_name.as_deref(), + limit, + offset, + ) + .await + .unwrap_or_default(); + let total = fc_common::repo::builds::count_filtered( + &state.pool, + None, + params.status.as_deref(), + params.system.as_deref(), + params.job_name.as_deref(), + ) + .await + .unwrap_or(0); + + let total_pages = (total + limit - 1) / limit.max(1); + let page = offset / limit.max(1) + 1; + let tmpl = BuildsTemplate { + builds: items.iter().map(build_view).collect(), + limit, + has_prev: offset > 0, + has_next: offset + limit < total, + prev_offset: (offset - limit).max(0), + next_offset: offset + limit, + page, + total_pages, + filter_status: params.status.unwrap_or_default(), + filter_system: params.system.unwrap_or_default(), + filter_job: params.job_name.unwrap_or_default(), + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +async fn build_page(State(state): State, Path(id): Path) -> Html { + let build = match fc_common::repo::builds::get(&state.pool, id).await { + Ok(b) => b, + Err(_) => return Html("Build not found".to_string()), + }; + + let eval = match fc_common::repo::evaluations::get(&state.pool, build.evaluation_id).await { + Ok(e) => e, + Err(_) => return Html("Evaluation not found".to_string()), + }; + let jobset = match fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await { + Ok(j) => j, + Err(_) => return Html("Jobset not found".to_string()), + }; + let project = match fc_common::repo::projects::get(&state.pool, jobset.project_id).await { + Ok(p) => p, + Err(_) => return Html("Project not found".to_string()), + }; + + let eval_commit_short = if eval.commit_hash.len() > 12 { + eval.commit_hash[..12].to_string() + } else { + eval.commit_hash.clone() + }; + + 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) + .await + .unwrap_or_default(); + + let tmpl = BuildTemplate { + build: build_view(&build), + steps, + products, + eval_id: eval.id, + eval_commit_short, + jobset_id: jobset.id, + jobset_name: jobset.name, + project_id: project.id, + project_name: project.name, + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +async fn queue_page(State(state): State) -> Html { + let running = fc_common::repo::builds::list_filtered( + &state.pool, + None, + Some("running"), + None, + None, + 100, + 0, + ) + .await + .unwrap_or_default(); + let pending = fc_common::repo::builds::list_filtered( + &state.pool, + None, + Some("pending"), + None, + None, + 100, + 0, + ) + .await + .unwrap_or_default(); + + let running_count = running.len() as i64; + let pending_count = pending.len() as i64; + + let tmpl = QueueTemplate { + running_builds: running.iter().map(build_view).collect(), + pending_builds: pending.iter().map(build_view).collect(), + running_count, + pending_count, + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +async fn channels_page(State(state): State) -> Html { + let channels = fc_common::repo::channels::list_all(&state.pool) + .await + .unwrap_or_default(); + + let tmpl = ChannelsTemplate { channels }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +async fn admin_page(State(state): State, extensions: Extensions) -> Html { + let pool = &state.pool; + + let projects: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects") + .fetch_one(pool) + .await + .unwrap_or((0,)); + let jobsets: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobsets") + .fetch_one(pool) + .await + .unwrap_or((0,)); + let evaluations: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations") + .fetch_one(pool) + .await + .unwrap_or((0,)); + let stats = fc_common::repo::builds::get_stats(pool) + .await + .unwrap_or_default(); + let builders_count = fc_common::repo::remote_builders::count(pool) + .await + .unwrap_or(0); + let channels: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM channels") + .fetch_one(pool) + .await + .unwrap_or((0,)); + + let status = SystemStatus { + projects_count: projects.0, + jobsets_count: jobsets.0, + evaluations_count: evaluations.0, + builds_pending: stats.pending_builds.unwrap_or(0), + builds_running: stats.running_builds.unwrap_or(0), + builds_completed: stats.completed_builds.unwrap_or(0), + builds_failed: stats.failed_builds.unwrap_or(0), + remote_builders: builders_count, + channels_count: channels.0, + }; + let builders = fc_common::repo::remote_builders::list(pool) + .await + .unwrap_or_default(); + + // Fetch API keys for admin view + let keys = fc_common::repo::api_keys::list(pool) + .await + .unwrap_or_default(); + let api_keys: Vec = keys + .into_iter() + .map(|k| ApiKeyView { + id: k.id, + name: k.name, + role: k.role, + created_at: k.created_at.format("%Y-%m-%d %H:%M").to_string(), + last_used_at: k + .last_used_at + .map(|t| t.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "Never".to_string()), + }) + .collect(); + + let tmpl = AdminTemplate { + status, + builders, + api_keys, + is_admin: is_admin(&extensions), + auth_name: auth_name(&extensions), + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +// --- Login / Logout --- + +async fn login_page() -> Html { + let tmpl = LoginTemplate { error: None }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) +} + +#[derive(serde::Deserialize)] +struct LoginForm { + api_key: String, +} + +async fn login_action(State(state): State, Form(form): Form) -> 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() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) + .into_response(); + } + + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let key_hash = hex::encode(hasher.finalize()); + + 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 { + api_key, + created_at: std::time::Instant::now(), + }, + ); + + let cookie = format!( + "fc_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400", + session_id + ); + ( + [(axum::http::header::SET_COOKIE, cookie)], + Redirect::to("/"), + ) + .into_response() + } + _ => { + let tmpl = LoginTemplate { + error: Some("Invalid API key".to_string()), + }; + Html( + tmpl.render() + .unwrap_or_else(|e| format!("Template error: {e}")), + ) + .into_response() + } + } +} + +async fn logout_action() -> Response { + let cookie = "fc_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"; + ( + [(axum::http::header::SET_COOKIE, cookie.to_string())], + Redirect::to("/"), + ) + .into_response() +} + +pub fn router(state: AppState) -> Router { + let _ = state; // used by middleware layer in mod.rs + Router::new() + .route("/login", get(login_page).post(login_action)) + .route("/logout", axum::routing::post(logout_action)) + .route("/", get(home)) + .route("/projects", get(projects_page)) + .route("/project/{id}", get(project_page)) + .route("/jobset/{id}", get(jobset_page)) + .route("/evaluations", get(evaluations_page)) + .route("/evaluation/{id}", get(evaluation_page)) + .route("/builds", get(builds_page)) + .route("/build/{id}", get(build_page)) + .route("/queue", get(queue_page)) + .route("/channels", get(channels_page)) + .route("/admin", get(admin_page)) +} diff --git a/crates/server/src/routes/evaluations.rs b/crates/server/src/routes/evaluations.rs new file mode 100644 index 0000000..53dba3b --- /dev/null +++ b/crates/server/src/routes/evaluations.rs @@ -0,0 +1,93 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + http::Extensions, + routing::{get, post}, +}; +use fc_common::{CreateEvaluation, Evaluation, PaginatedResponse, PaginationParams, Validate}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth_middleware::RequireRoles; +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +struct ListEvaluationsParams { + jobset_id: Option, + status: Option, + limit: Option, + offset: Option, +} + +async fn list_evaluations( + State(state): State, + Query(params): Query, +) -> Result>, ApiError> { + let pagination = PaginationParams { + limit: params.limit, + offset: params.offset, + }; + let limit = pagination.limit(); + let offset = pagination.offset(); + let items = fc_common::repo::evaluations::list_filtered( + &state.pool, + params.jobset_id, + params.status.as_deref(), + limit, + offset, + ) + .await + .map_err(ApiError)?; + let total = fc_common::repo::evaluations::count_filtered( + &state.pool, + params.jobset_id, + params.status.as_deref(), + ) + .await + .map_err(ApiError)?; + Ok(Json(PaginatedResponse { + items, + total, + limit, + offset, + })) +} + +async fn get_evaluation( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let evaluation = fc_common::repo::evaluations::get(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(evaluation)) +} + +async fn trigger_evaluation( + extensions: Extensions, + State(state): State, + Json(input): Json, +) -> Result, ApiError> { + RequireRoles::check(&extensions, &["eval-jobset"]).map_err(|s| { + ApiError(if s == axum::http::StatusCode::FORBIDDEN { + fc_common::CiError::Forbidden("Insufficient permissions".to_string()) + } else { + fc_common::CiError::Unauthorized("Authentication required".to_string()) + }) + })?; + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let evaluation = fc_common::repo::evaluations::create(&state.pool, input) + .await + .map_err(ApiError)?; + Ok(Json(evaluation)) +} + +pub fn router() -> Router { + Router::new() + .route("/evaluations", get(list_evaluations)) + .route("/evaluations/{id}", get(get_evaluation)) + .route("/evaluations/trigger", post(trigger_evaluation)) +} diff --git a/crates/server/src/routes/health.rs b/crates/server/src/routes/health.rs new file mode 100644 index 0000000..b2538bd --- /dev/null +++ b/crates/server/src/routes/health.rs @@ -0,0 +1,28 @@ +use axum::{Json, Router, extract::State, routing::get}; +use serde::Serialize; + +use crate::state::AppState; + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + database: bool, +} + +async fn health_check(State(state): State) -> Json { + let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&state.pool) + .await + .is_ok(); + + let status = if db_ok { "ok" } else { "degraded" }; + + Json(HealthResponse { + status, + database: db_ok, + }) +} + +pub fn router() -> Router { + Router::new().route("/health", get(health_check)) +} diff --git a/crates/server/src/routes/jobsets.rs b/crates/server/src/routes/jobsets.rs new file mode 100644 index 0000000..74501a6 --- /dev/null +++ b/crates/server/src/routes/jobsets.rs @@ -0,0 +1,54 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + routing::get, +}; +use fc_common::{Jobset, UpdateJobset, Validate}; +use uuid::Uuid; + +use crate::auth_middleware::RequireAdmin; +use crate::error::ApiError; +use crate::state::AppState; + +async fn get_jobset( + State(state): State, + Path((_project_id, id)): Path<(Uuid, Uuid)>, +) -> Result, ApiError> { + let jobset = fc_common::repo::jobsets::get(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(jobset)) +} + +async fn update_jobset( + _auth: RequireAdmin, + State(state): State, + Path((_project_id, id)): Path<(Uuid, Uuid)>, + Json(input): Json, +) -> Result, ApiError> { + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let jobset = fc_common::repo::jobsets::update(&state.pool, id, input) + .await + .map_err(ApiError)?; + Ok(Json(jobset)) +} + +async fn delete_jobset( + _auth: RequireAdmin, + State(state): State, + Path((_project_id, id)): Path<(Uuid, Uuid)>, +) -> Result, ApiError> { + fc_common::repo::jobsets::delete(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(serde_json::json!({ "deleted": true }))) +} + +pub fn router() -> Router { + Router::new().route( + "/projects/{project_id}/jobsets/{id}", + get(get_jobset).put(update_jobset).delete(delete_jobset), + ) +} diff --git a/crates/server/src/routes/logs.rs b/crates/server/src/routes/logs.rs new file mode 100644 index 0000000..30fd6d0 --- /dev/null +++ b/crates/server/src/routes/logs.rs @@ -0,0 +1,126 @@ +use axum::response::sse::{Event, KeepAlive}; +use axum::{ + Router, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response, Sse}, + routing::get, +}; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::state::AppState; + +async fn get_build_log( + State(state): State, + Path(id): Path, +) -> Result { + // Verify build exists + 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()) + .map_err(|e| ApiError(fc_common::CiError::Io(e)))?; + + match log_storage.read_log(&id) { + 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()), + Err(e) => Err(ApiError(fc_common::CiError::Io(e))), + } +} + +async fn stream_build_log( + State(state): State, + Path(id): Path, +) -> Result>>, 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()) + .map_err(|e| ApiError(fc_common::CiError::Io(e)))?; + + let active_path = log_storage.log_path_for_active(&id); + let final_path = log_storage.log_path(&id); + let pool = state.pool.clone(); + let build_id = build.id; + + let stream = async_stream::stream! { + use tokio::io::{AsyncBufReadExt, BufReader}; + + // Determine which file to read + let path = if active_path.exists() { + active_path.clone() + } else if final_path.exists() { + final_path.clone() + } else { + // Wait for the file to appear + let mut found = false; + for _ in 0..30 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + if active_path.exists() || final_path.exists() { + found = true; + break; + } + } + if !found { + yield Ok(Event::default().data("No log file available")); + return; + } + if active_path.exists() { active_path.clone() } else { final_path.clone() } + }; + + let file = match tokio::fs::File::open(&path).await { + Ok(f) => f, + Err(_) => { + yield Ok(Event::default().data("Failed to open log file")); + return; + } + }; + + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut consecutive_empty = 0u32; + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => { + // EOF — check if build is still running + consecutive_empty += 1; + if consecutive_empty > 5 { + // Check build status + if let Ok(b) = fc_common::repo::builds::get(&pool, build_id).await { + if b.status != fc_common::models::BuildStatus::Running + && b.status != fc_common::models::BuildStatus::Pending { + yield Ok(Event::default().event("done").data("Build completed")); + return; + } + } + consecutive_empty = 0; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + Ok(_) => { + consecutive_empty = 0; + yield Ok(Event::default().data(line.trim_end())); + } + Err(_) => return, + } + } + }; + + Ok(Sse::new(stream).keep_alive(KeepAlive::default())) +} + +pub fn router() -> Router { + Router::new() + .route("/builds/{id}/log", get(get_build_log)) + .route("/builds/{id}/log/stream", get(stream_build_log)) +} diff --git a/crates/server/src/routes/metrics.rs b/crates/server/src/routes/metrics.rs new file mode 100644 index 0000000..98d678b --- /dev/null +++ b/crates/server/src/routes/metrics.rs @@ -0,0 +1,188 @@ +use axum::{ + Router, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; + +use crate::state::AppState; + +async fn prometheus_metrics(State(state): State) -> Response { + let stats = match fc_common::repo::builds::get_stats(&state.pool).await { + Ok(s) => s, + Err(_) => { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let eval_count: i64 = match sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM evaluations") + .fetch_one(&state.pool) + .await + { + Ok(row) => row.0, + Err(_) => 0, + }; + + 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)", + ) + .fetch_one(&state.pool) + .await + .unwrap_or((0, 0, 0)); + + // 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", + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + // Build duration percentiles (single query) + let (duration_p50, duration_p95, duration_p99): (Option, Option, Option) = + 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 + .unwrap_or((None, None, None)); + + let mut output = String::new(); + + // Build counts by status + output.push_str("# HELP fc_builds_total Total number of builds by status\n"); + output.push_str("# TYPE fc_builds_total gauge\n"); + output.push_str(&format!( + "fc_builds_total{{status=\"completed\"}} {}\n", + stats.completed_builds.unwrap_or(0) + )); + output.push_str(&format!( + "fc_builds_total{{status=\"failed\"}} {}\n", + stats.failed_builds.unwrap_or(0) + )); + output.push_str(&format!( + "fc_builds_total{{status=\"running\"}} {}\n", + stats.running_builds.unwrap_or(0) + )); + output.push_str(&format!( + "fc_builds_total{{status=\"pending\"}} {}\n", + stats.pending_builds.unwrap_or(0) + )); + output.push_str(&format!( + "fc_builds_total{{status=\"all\"}} {}\n", + stats.total_builds.unwrap_or(0) + )); + + // Build duration stats + 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("# TYPE fc_builds_duration_seconds gauge\n"); + if let Some(p50) = duration_p50 { + output.push_str(&format!( + "fc_builds_duration_seconds{{quantile=\"0.5\"}} {p50:.2}\n" + )); + } + if let Some(p95) = duration_p95 { + output.push_str(&format!( + "fc_builds_duration_seconds{{quantile=\"0.95\"}} {p95:.2}\n" + )); + } + if let Some(p99) = duration_p99 { + output.push_str(&format!( + "fc_builds_duration_seconds{{quantile=\"0.99\"}} {p99:.2}\n" + )); + } + + // Evaluations + 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)); + + output.push_str("\n# HELP fc_evaluations_by_status Evaluations by status\n"); + output.push_str("# TYPE fc_evaluations_by_status gauge\n"); + for (status, count) in &eval_by_status { + output.push_str(&format!( + "fc_evaluations_by_status{{status=\"{status}\"}} {count}\n" + )); + } + + // Queue depth (pending builds) + 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", + stats.pending_builds.unwrap_or(0) + )); + + // Infrastructure + output.push_str("\n# HELP fc_projects_total Total number of projects\n"); + output.push_str("# TYPE fc_projects_total gauge\n"); + output.push_str(&format!("fc_projects_total {project_count}\n")); + + output.push_str("\n# HELP fc_channels_total Total number of channels\n"); + 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("# 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("# 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("# TYPE fc_project_builds_failed gauge\n"); + for (name, _, failed) in &per_project { + output.push_str(&format!( + "fc_project_builds_failed{{project=\"{name}\"}} {failed}\n" + )); + } + } + + ( + StatusCode::OK, + [("content-type", "text/plain; version=0.0.4; charset=utf-8")], + output, + ) + .into_response() +} + +pub fn router() -> Router { + Router::new().route("/metrics", get(prometheus_metrics)) +} diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs new file mode 100644 index 0000000..74c1c43 --- /dev/null +++ b/crates/server/src/routes/mod.rs @@ -0,0 +1,156 @@ +pub mod admin; +pub mod auth; +pub mod badges; +pub mod builds; +pub mod cache; +pub mod channels; +pub mod dashboard; +pub mod evaluations; +pub mod health; +pub mod jobsets; +pub mod logs; +pub mod metrics; +pub mod projects; +pub mod search; +pub mod webhooks; + +use std::net::IpAddr; +use std::sync::Arc; +use std::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 dashmap::DashMap; +use fc_common::config::ServerConfig; +use tower_http::cors::{AllowOrigin, CorsLayer}; +use tower_http::limit::RequestBodyLimitLayer; +use tower_http::trace::TraceLayer; + +use crate::auth_middleware::{extract_session, require_api_key}; +use crate::state::AppState; + +struct RateLimitState { + requests: DashMap>, + _rps: u64, + burst: u32, + last_cleanup: std::sync::atomic::AtomicU64, +} + +async fn rate_limit_middleware( + ConnectInfo(addr): ConnectInfo, + request: Request, + next: Next, +) -> Response { + let state = request.extensions().get::>().cloned(); + + if let Some(rl) = state { + let ip = addr.ip(); + let now = Instant::now(); + let window = std::time::Duration::from_secs(1); + + // Periodic cleanup of stale entries (every 60 seconds) + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let last = rl.last_cleanup.load(std::sync::atomic::Ordering::Relaxed); + if now_secs - last > 60 { + if rl + .last_cleanup + .compare_exchange( + last, + now_secs, + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::Relaxed, + ) + .is_ok() + { + rl.requests.retain(|_, v| { + v.retain(|t| now.duration_since(*t) < std::time::Duration::from_secs(10)); + !v.is_empty() + }); + } + } + + let mut entry = rl.requests.entry(ip).or_default(); + entry.retain(|t| now.duration_since(*t) < window); + + if entry.len() >= rl.burst as usize { + return StatusCode::TOO_MANY_REQUESTS.into_response(); + } + + entry.push(now); + drop(entry); + } + + next.run(request).await +} + +pub fn router(state: AppState, config: &ServerConfig) -> Router { + let cors_layer = if config.cors_permissive { + CorsLayer::permissive() + } else if config.allowed_origins.is_empty() { + CorsLayer::new() + } else { + let origins: Vec = config + .allowed_origins + .iter() + .filter_map(|o| o.parse().ok()) + .collect(); + CorsLayer::new().allow_origin(AllowOrigin::list(origins)) + }; + + let mut app = Router::new() + // Dashboard routes with session extraction middleware + .merge( + dashboard::router(state.clone()).route_layer(middleware::from_fn_with_state( + state.clone(), + extract_session, + )), + ) + .merge(health::router()) + .merge(cache::router()) + .merge(metrics::router()) + // Webhooks use their own HMAC auth, outside the API key gate + .merge(webhooks::router()) + // API routes with Bearer token auth + .nest( + "/api/v1", + Router::new() + .merge(projects::router()) + .merge(jobsets::router()) + .merge(evaluations::router()) + .merge(builds::router()) + .merge(logs::router()) + .merge(auth::router()) + .merge(search::router()) + .merge(badges::router()) + .merge(channels::router()) + .merge(admin::router()) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_api_key, + )), + ) + .layer(TraceLayer::new_for_http()) + .layer(cors_layer) + .layer(RequestBodyLimitLayer::new(config.max_body_size)); + + // Add rate limiting if configured + 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, + burst, + last_cleanup: std::sync::atomic::AtomicU64::new(0), + }); + app = app + .layer(axum::Extension(rl_state)) + .layer(middleware::from_fn(rate_limit_middleware)); + } + + app.with_state(state) +} diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs new file mode 100644 index 0000000..034fc21 --- /dev/null +++ b/crates/server/src/routes/projects.rs @@ -0,0 +1,166 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + http::Extensions, + routing::get, +}; +use fc_common::{ + CreateJobset, CreateProject, Jobset, PaginatedResponse, PaginationParams, Project, + UpdateProject, Validate, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth_middleware::{RequireAdmin, RequireRoles}; +use crate::error::ApiError; +use crate::state::AppState; + +async fn list_projects( + State(state): State, + Query(pagination): Query, +) -> Result>, ApiError> { + let limit = pagination.limit(); + let offset = pagination.offset(); + let items = fc_common::repo::projects::list(&state.pool, limit, offset) + .await + .map_err(ApiError)?; + let total = fc_common::repo::projects::count(&state.pool) + .await + .map_err(ApiError)?; + Ok(Json(PaginatedResponse { + items, + total, + limit, + offset, + })) +} + +async fn create_project( + extensions: Extensions, + State(state): State, + Json(input): Json, +) -> Result, ApiError> { + RequireRoles::check(&extensions, &["create-projects"]).map_err(|s| { + ApiError(if s == axum::http::StatusCode::FORBIDDEN { + fc_common::CiError::Forbidden("Insufficient permissions".to_string()) + } else { + fc_common::CiError::Unauthorized("Authentication required".to_string()) + }) + })?; + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let project = fc_common::repo::projects::create(&state.pool, input) + .await + .map_err(ApiError)?; + Ok(Json(project)) +} + +async fn get_project( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let project = fc_common::repo::projects::get(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(project)) +} + +async fn update_project( + _auth: RequireAdmin, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, ApiError> { + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let project = fc_common::repo::projects::update(&state.pool, id, input) + .await + .map_err(ApiError)?; + Ok(Json(project)) +} + +async fn delete_project( + _auth: RequireAdmin, + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + fc_common::repo::projects::delete(&state.pool, id) + .await + .map_err(ApiError)?; + Ok(Json(serde_json::json!({ "deleted": true }))) +} + +async fn list_project_jobsets( + State(state): State, + Path(id): Path, + Query(pagination): Query, +) -> Result>, ApiError> { + let limit = pagination.limit(); + let offset = pagination.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) + .await + .map_err(ApiError)?; + Ok(Json(PaginatedResponse { + items, + total, + limit, + offset, + })) +} + +#[derive(Debug, Deserialize)] +struct CreateJobsetBody { + name: String, + nix_expression: String, + enabled: Option, + flake_mode: Option, + check_interval: Option, +} + +async fn create_project_jobset( + extensions: Extensions, + State(state): State, + Path(project_id): Path, + Json(body): Json, +) -> Result, ApiError> { + RequireRoles::check(&extensions, &["create-projects"]).map_err(|s| { + ApiError(if s == axum::http::StatusCode::FORBIDDEN { + fc_common::CiError::Forbidden("Insufficient permissions".to_string()) + } else { + fc_common::CiError::Unauthorized("Authentication required".to_string()) + }) + })?; + let input = CreateJobset { + project_id, + name: body.name, + nix_expression: body.nix_expression, + enabled: body.enabled, + flake_mode: body.flake_mode, + check_interval: body.check_interval, + }; + input + .validate() + .map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?; + let jobset = fc_common::repo::jobsets::create(&state.pool, input) + .await + .map_err(ApiError)?; + Ok(Json(jobset)) +} + +pub fn router() -> Router { + Router::new() + .route("/projects", get(list_projects).post(create_project)) + .route( + "/projects/{id}", + get(get_project).put(update_project).delete(delete_project), + ) + .route( + "/projects/{id}/jobsets", + get(list_project_jobsets).post(create_project_jobset), + ) +} diff --git a/crates/server/src/routes/search.rs b/crates/server/src/routes/search.rs new file mode 100644 index 0000000..48424f4 --- /dev/null +++ b/crates/server/src/routes/search.rs @@ -0,0 +1,58 @@ +use axum::{ + 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; + +#[derive(Debug, Deserialize)] +struct SearchParams { + q: String, +} + +#[derive(Debug, Serialize)] +struct SearchResults { + projects: Vec, + builds: Vec, +} + +async fn search( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let query = params.q.trim(); + if query.is_empty() || query.len() > 256 { + return Ok(Json(SearchResults { + projects: vec![], + builds: vec![], + })); + } + + 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", + ) + .bind(&pattern) + .fetch_all(&state.pool) + .await + .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", + ) + .bind(&pattern) + .fetch_all(&state.pool) + .await + .map_err(|e| ApiError(fc_common::CiError::Database(e)))?; + + Ok(Json(SearchResults { projects, builds })) +} + +pub fn router() -> Router { + Router::new().route("/search", get(search)) +} diff --git a/crates/server/src/routes/webhooks.rs b/crates/server/src/routes/webhooks.rs new file mode 100644 index 0000000..6ac6fb5 --- /dev/null +++ b/crates/server/src/routes/webhooks.rs @@ -0,0 +1,302 @@ +use axum::{ + Json, Router, + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + routing::post, +}; +use fc_common::models::CreateEvaluation; +use fc_common::repo; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +struct WebhookResponse { + accepted: bool, + message: String, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GithubPushPayload { + #[serde(alias = "ref")] + git_ref: Option, + after: Option, + repository: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GithubRepo { + clone_url: Option, + html_url: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GiteaPushPayload { + #[serde(alias = "ref")] + git_ref: Option, + after: Option, + repository: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct GiteaRepo { + clone_url: Option, + html_url: Option, +} + +/// Verify HMAC-SHA256 webhook signature. +/// The `secret` parameter is the raw webhook secret stored in DB. +fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let Ok(mut mac) = Hmac::::new_from_slice(secret.as_bytes()) else { + return false; + }; + mac.update(body); + + // Parse the hex signature (strip "sha256=" prefix if present) + let hex_sig = signature + .strip_prefix("sha256=") + .or_else(|| signature.strip_prefix("sha1=")) + .unwrap_or(signature); + + let Ok(sig_bytes) = hex::decode(hex_sig) else { + return false; + }; + + mac.verify_slice(&sig_bytes).is_ok() +} + +async fn handle_github_push( + State(state): State, + Path(project_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result<(StatusCode, Json), ApiError> { + // Check webhook config exists + let webhook_config = + repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, "github") + .await + .map_err(ApiError)?; + + let webhook_config = match webhook_config { + Some(c) => c, + None => { + return Ok(( + StatusCode::NOT_FOUND, + Json(WebhookResponse { + accepted: false, + message: "No GitHub webhook configured for this project".to_string(), + }), + )); + } + }; + + // Verify signature if secret is configured + if let Some(ref secret_hash) = webhook_config.secret_hash { + let signature = headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !verify_signature(secret_hash, &body, signature) { + return Ok(( + StatusCode::UNAUTHORIZED, + Json(WebhookResponse { + accepted: false, + message: "Invalid webhook signature".to_string(), + }), + )); + } + } + + // Parse payload + let payload: GithubPushPayload = serde_json::from_slice(&body).map_err(|e| { + ApiError(fc_common::CiError::Validation(format!( + "Invalid payload: {e}" + ))) + })?; + + let commit = payload.after.unwrap_or_default(); + if commit.is_empty() || commit == "0000000000000000000000000000000000000000" { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "Branch deletion event, skipping".to_string(), + }), + )); + } + + // Find matching jobsets for this project and trigger evaluations + let jobsets = repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0) + .await + .map_err(ApiError)?; + + let mut triggered = 0; + for jobset in &jobsets { + if !jobset.enabled { + continue; + } + 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(e) => tracing::warn!("Failed to create evaluation: {e}"), + } + } + + Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!("Triggered {triggered} evaluations for commit {commit}"), + }), + )) +} + +async fn handle_gitea_push( + State(state): State, + Path(project_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Result<(StatusCode, Json), ApiError> { + // Check webhook config exists + let forge_type = if headers.get("x-forgejo-event").is_some() { + "forgejo" + } else { + "gitea" + }; + + let webhook_config = + repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, forge_type) + .await + .map_err(ApiError)?; + + // Fall back to the other type if not found + let webhook_config = match webhook_config { + Some(c) => c, + None => { + let alt = if forge_type == "gitea" { + "forgejo" + } else { + "gitea" + }; + match repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, alt) + .await + .map_err(ApiError)? + { + Some(c) => c, + None => { + return Ok(( + StatusCode::NOT_FOUND, + Json(WebhookResponse { + accepted: false, + message: "No Gitea/Forgejo webhook configured for this project" + .to_string(), + }), + )); + } + } + } + }; + + // Verify signature if configured + if let Some(ref secret_hash) = webhook_config.secret_hash { + let signature = headers + .get("x-gitea-signature") + .or_else(|| headers.get("x-forgejo-signature")) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !verify_signature(secret_hash, &body, signature) { + return Ok(( + StatusCode::UNAUTHORIZED, + Json(WebhookResponse { + accepted: false, + message: "Invalid webhook signature".to_string(), + }), + )); + } + } + + let payload: GiteaPushPayload = serde_json::from_slice(&body).map_err(|e| { + ApiError(fc_common::CiError::Validation(format!( + "Invalid payload: {e}" + ))) + })?; + + let commit = payload.after.unwrap_or_default(); + if commit.is_empty() || commit == "0000000000000000000000000000000000000000" { + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: "Branch deletion event, skipping".to_string(), + }), + )); + } + + let jobsets = repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0) + .await + .map_err(ApiError)?; + + let mut triggered = 0; + for jobset in &jobsets { + if !jobset.enabled { + continue; + } + 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(e) => tracing::warn!("Failed to create evaluation: {e}"), + } + } + + Ok(( + StatusCode::OK, + Json(WebhookResponse { + accepted: true, + message: format!("Triggered {triggered} evaluations for commit {commit}"), + }), + )) +} + +pub fn router() -> Router { + Router::new() + .route( + "/api/v1/webhooks/{project_id}/github", + post(handle_github_push), + ) + .route( + "/api/v1/webhooks/{project_id}/gitea", + post(handle_gitea_push), + ) + .route( + "/api/v1/webhooks/{project_id}/forgejo", + post(handle_gitea_push), + ) +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs new file mode 100644 index 0000000..0c4dcf6 --- /dev/null +++ b/crates/server/src/state.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; +use std::time::Instant; + +use dashmap::DashMap; +use fc_common::config::Config; +use fc_common::models::ApiKey; +use sqlx::PgPool; + +pub struct SessionData { + pub api_key: ApiKey, + pub created_at: Instant, +} + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub config: Config, + pub sessions: Arc>, +} diff --git a/crates/server/static/style.css b/crates/server/static/style.css new file mode 100644 index 0000000..7bf3c83 --- /dev/null +++ b/crates/server/static/style.css @@ -0,0 +1,186 @@ +/* FC CI Dashboard Styles */ + +:root { + --bg: #fafafa; + --fg: #1a1a1a; + --border: #ddd; + --accent: #2563eb; + --muted: #6b7280; + --card-bg: #fff; + --green: #16a34a; + --red: #dc2626; + --yellow: #ca8a04; + --gray: #6b7280; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.6; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +code { + background: #f3f4f6; + padding: 0.1em 0.3em; + border-radius: 3px; + font-size: 0.9em; +} + +.navbar { + display: flex; + align-items: center; + gap: 2rem; + padding: 0.75rem 1.5rem; + background: var(--card-bg); + border-bottom: 1px solid var(--border); +} + +.nav-brand a { + font-weight: 700; + font-size: 1.1rem; + color: var(--fg); +} + +.nav-links { display: flex; gap: 1rem; } +.nav-links a { color: var(--muted); font-size: 0.9rem; } +.nav-links a:hover { color: var(--fg); } + +.container { + max-width: 1100px; + margin: 1.5rem auto; + padding: 0 1rem; +} + +h1 { margin-bottom: 1rem; font-size: 1.5rem; } +h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; } + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem; + text-align: center; +} + +.stat-value { font-size: 1.75rem; font-weight: 700; } +.stat-label { font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } + +table { + width: 100%; + border-collapse: collapse; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +th, td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +th { + background: #f9fafb; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--muted); +} + +tbody tr:last-child td { border-bottom: none; } +tbody tr:hover { background: #f9fafb; } + +.badge { + display: inline-block; + padding: 0.15em 0.5em; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 600; + text-transform: capitalize; +} + +.badge-completed { background: #dcfce7; color: var(--green); } +.badge-failed { background: #fee2e2; color: var(--red); } +.badge-running { background: #fef9c3; color: var(--yellow); } +.badge-pending { background: #f3f4f6; color: var(--gray); } +.badge-cancelled { background: #f3f4f6; color: var(--gray); } + +.empty { color: var(--muted); font-style: italic; } + +.pagination { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1rem; + font-size: 0.9rem; + color: var(--muted); +} + +.filter-form { + display: flex; + gap: 1rem; + align-items: end; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.filter-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: var(--muted); +} + +.filter-form select, +.filter-form input { + padding: 0.35rem 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.9rem; +} + +.filter-form button { + padding: 0.4rem 1rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; +} + +.filter-form button:hover { opacity: 0.9; } + +.footer { + text-align: center; + padding: 2rem 1rem; + color: var(--muted); + font-size: 0.8rem; + border-top: 1px solid var(--border); + margin-top: 3rem; +} + +@media (max-width: 768px) { + .navbar { flex-direction: column; gap: 0.5rem; } + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .filter-form { flex-direction: column; } + table { font-size: 0.85rem; } + th, td { padding: 0.35rem 0.5rem; } +} diff --git a/crates/server/templates/admin.html b/crates/server/templates/admin.html new file mode 100644 index 0000000..d0f5884 --- /dev/null +++ b/crates/server/templates/admin.html @@ -0,0 +1,205 @@ +{% extends "base.html" %} +{% block title %}Admin - FC CI{% endblock %} +{% block auth %} +{% if !auth_name.is_empty() %} +{{ auth_name }} +
+{% else %} +Login +{% endif %} +{% endblock %} +{% block content %} +

Administration

+ +

System Status

+
+
{{ status.projects_count }}
Projects
+
{{ status.jobsets_count }}
Jobsets
+
{{ status.evaluations_count }}
Evaluations
+
{{ status.builds_pending }}
Pending
+
{{ status.builds_running }}
Running
+
{{ status.builds_completed }}
Completed
+
{{ status.builds_failed }}
Failed
+
{{ status.channels_count }}
Channels
+
+ +{% if is_admin %} +

API Keys

+
+ Create API Key +
+
+
+ + +
+
+ + +
+ +
+
+
+
+{% if api_keys.is_empty() %} +

No API keys.

+{% else %} + + + {% if is_admin %}{% endif %} + + + {% for k in api_keys %} + + + + + + {% if is_admin %} + + {% endif %} + + {% endfor %} + +
NameRoleCreatedLast UsedActions
{{ k.name }}{{ k.role }}{{ k.created_at }}{{ k.last_used_at }}
+{% endif %} +{% endif %} + +

Remote Builders

+{% if is_admin %} +
+ Add Remote Builder +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+{% endif %} +{% if builders.is_empty() %} +

No remote builders configured.

+{% else %} + + + {% if is_admin %}{% endif %} + + + {% for b in builders %} + + + + + + + {% if is_admin %} + + {% endif %} + + {% endfor %} + +
NameSSH URISystemsMax JobsEnabledActions
{{ b.name }}{{ b.ssh_uri }}{{ b.systems.join(", ") }}{{ b.max_jobs }}{% if b.enabled %}Yes{% else %}No{% endif %} + + +
+{% endif %} +{% endblock %} +{% block scripts %} +{% if is_admin %} + +{% endif %} +{% endblock %} diff --git a/crates/server/templates/base.html b/crates/server/templates/base.html new file mode 100644 index 0000000..5633143 --- /dev/null +++ b/crates/server/templates/base.html @@ -0,0 +1,403 @@ + + + + + + {% block title %}FC CI{% endblock %} + + + + +
+ {% block breadcrumbs %}{% endblock %} + {% block content %}{% endblock %} +
+
+

FC CI — Nix-based continuous integration

+
+ {% block scripts %}{% endblock %} + + diff --git a/crates/server/templates/build.html b/crates/server/templates/build.html new file mode 100644 index 0000000..bc9cadd --- /dev/null +++ b/crates/server/templates/build.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}Build {{ build.job_name }} - FC CI{% endblock %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +

Build: {{ build.job_name }}

+ +
+
Status
+
{{ build.status_text }}
+
System
+
{{ build.system }}
+
Derivation
+
{{ build.drv_path }}
+
Created
+
{{ build.created_at }}
+ {% if !build.started_at.is_empty() %} +
Started
+
{{ build.started_at }}
+ {% endif %} + {% if !build.completed_at.is_empty() %} +
Completed
+
{{ build.completed_at }}
+ {% endif %} + {% if !build.duration.is_empty() %} +
Duration
+
{{ build.duration }}
+ {% endif %} +
Priority
+
{{ build.priority }}
+
Signed
+
{% if build.signed %}Yes{% else %}No{% endif %}
+ {% if build.is_aggregate %} +
Aggregate
+
Yes
+ {% endif %} +
+ +{% if !build.output_path.is_empty() %} +

Output: {{ build.output_path }}

+{% endif %} +{% if !build.error_message.is_empty() %} +

Error: {{ build.error_message }}

+{% endif %} +{% if !build.log_url.is_empty() %} +

View log

+{% endif %} + +

Build Steps

+{% if steps.is_empty() %} +

No steps recorded.

+{% else %} + + + + + + {% for s in steps %} + + + + + + + + {% endfor %} + +
#CommandExitStartedCompleted
{{ s.step_number }}{{ s.command }}{% match s.exit_code %}{% when Some with (0) %}0{% when Some with (code) %}{{ code }}{% when None %}-{% endmatch %}{{ s.started_at.format("%H:%M:%S") }}{% match s.completed_at %}{% when Some with (t) %}{{ t.format("%H:%M:%S") }}{% when None %}-{% endmatch %}
+{% endif %} + +

Build Products

+{% if products.is_empty() %} +

No products recorded.

+{% else %} + + + + + + {% for p in products %} + + + + + + {% endfor %} + +
NamePathSize
{{ p.name }}{{ p.path }}{% match p.file_size %}{% when Some with (sz) %}{{ sz }} bytes{% when None %}-{% endmatch %}
+{% endif %} +{% endblock %} diff --git a/crates/server/templates/builds.html b/crates/server/templates/builds.html new file mode 100644 index 0000000..adc94a2 --- /dev/null +++ b/crates/server/templates/builds.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Builds - FC CI{% endblock %} +{% block content %} +

Builds

+ +
+ + + + +
+ +{% if builds.is_empty() %} +

No builds match filters.

+{% else %} + + + + + + {% for b in builds %} + + + + + + + {% endfor %} + +
JobStatusSystemCreated
{{ b.job_name }}{{ b.status_text }}{{ b.system }}{{ b.created_at }}
+{% endif %} + +{% endblock %} diff --git a/crates/server/templates/channels.html b/crates/server/templates/channels.html new file mode 100644 index 0000000..53529eb --- /dev/null +++ b/crates/server/templates/channels.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Channels - FC CI{% endblock %} +{% block content %} +

Channels

+{% if channels.is_empty() %} +

No channels configured.

+{% else %} + + + + + + {% for c in channels %} + + + + + + {% endfor %} + +
NameCurrent EvaluationUpdated
{{ c.name }} + {% match c.current_evaluation_id %} + {% when Some with (eval_id) %} + {{ eval_id }} + {% when None %} + - + {% endmatch %} + {{ c.updated_at.format("%Y-%m-%d %H:%M") }}
+{% endif %} +{% endblock %} diff --git a/crates/server/templates/evaluation.html b/crates/server/templates/evaluation.html new file mode 100644 index 0000000..c90e604 --- /dev/null +++ b/crates/server/templates/evaluation.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}Evaluation {{ eval.commit_short }} - FC CI{% endblock %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +

Evaluation {{ eval.commit_short }}

+

Status: {{ eval.status_text }}

+

Commit: {{ eval.commit_hash }}

+

Time: {{ eval.time }}

+{% match eval.error_message %} + {% when Some with (err) %} +

Error: {{ err }}

+ {% when None %} +{% endmatch %} + +
+
+
{{ succeeded_count }}
+
Succeeded
+
+
+
{{ failed_count }}
+
Failed
+
+
+
{{ running_count }}
+
Running
+
+
+
{{ pending_count }}
+
Pending
+
+
+ +

Builds

+{% if builds.is_empty() %} +

No builds for this evaluation.

+{% else %} + + + + + + {% for b in builds %} + + + + + + + {% endfor %} + +
JobStatusSystemCreated
{{ b.job_name }}{{ b.status_text }}{{ b.system }}{{ b.created_at }}
+{% endif %} +{% endblock %} diff --git a/crates/server/templates/evaluations.html b/crates/server/templates/evaluations.html new file mode 100644 index 0000000..922ffe0 --- /dev/null +++ b/crates/server/templates/evaluations.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Evaluations - FC CI{% endblock %} +{% block content %} +

Evaluations

+{% if evals.is_empty() %} +

No evaluations yet.

+{% else %} + + + + + + {% for e in evals %} + + + + + + + + {% endfor %} + +
CommitProjectJobsetStatusTime
{{ e.commit_short }}{{ e.project_name }}{{ e.jobset_name }}{{ e.status_text }}{{ e.time }}
+{% endif %} + +{% endblock %} diff --git a/crates/server/templates/home.html b/crates/server/templates/home.html new file mode 100644 index 0000000..f5507c5 --- /dev/null +++ b/crates/server/templates/home.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% block title %}FC CI - Dashboard{% endblock %} +{% block auth %} +{% if !auth_name.is_empty() %} +{{ auth_name }} +
+{% else %} +Login +{% endif %} +{% endblock %} +{% block content %} +

Dashboard

+ +
+
+
{{ total_builds }}
+
Total Builds
+
+
+
{{ completed_builds }}
+
Completed
+
+
+
{{ failed_builds }}
+
Failed
+
+
+
{{ running_builds }}
+
Running
+
+
+
{{ pending_builds }}
+
Pending
+
+
+ +

+ Queue: {{ pending_builds }} pending, {{ running_builds }} running +

+ +{% if !projects.is_empty() %} +

Projects Overview

+ + + + + + {% for p in projects %} + + + + + + + {% endfor %} + +
ProjectJobsetsLast EvalTime
{{ p.name }}{{ p.jobset_count }}{{ p.last_eval_status }}{{ p.last_eval_time }}
+{% endif %} + +

Recent Builds

+{% if recent_builds.is_empty() %} +

No builds yet.

+{% else %} + + + + + + {% for b in recent_builds %} + + + + + + + {% endfor %} + +
JobStatusSystemCreated
{{ b.job_name }}{{ b.status_text }}{{ b.system }}{{ b.created_at }}
+{% endif %} + +

Recent Evaluations

+{% if recent_evals.is_empty() %} +

No evaluations yet.

+{% else %} + + + + + + {% for e in recent_evals %} + + + + + + {% endfor %} + +
CommitStatusTime
{{ e.commit_short }}{{ e.status_text }}{{ e.time }}
+{% endif %} +{% endblock %} diff --git a/crates/server/templates/jobset.html b/crates/server/templates/jobset.html new file mode 100644 index 0000000..10f4f5b --- /dev/null +++ b/crates/server/templates/jobset.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}{{ jobset.name }} - FC CI{% endblock %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +

{{ jobset.name }}

+ +
+
Expression
+
{{ jobset.nix_expression }}
+
Flake mode
+
{% if jobset.flake_mode %}Yes{% else %}No{% endif %}
+
Enabled
+
{% if jobset.enabled %}Yes{% else %}No{% endif %}
+
Check interval
+
{{ jobset.check_interval }}s
+
+ +{% if !eval_summaries.is_empty() %} +

Latest Evaluation

+{% let latest = eval_summaries[0] %} +
+
+
{{ latest.succeeded }}
+
Succeeded
+
+
+
{{ latest.failed }}
+
Failed
+
+
+
{{ latest.pending }}
+
Pending
+
+
+{% endif %} + +

Recent Evaluations

+{% if eval_summaries.is_empty() %} +

No evaluations yet.

+{% else %} + + + + + + {% for e in eval_summaries %} + + + + + + + + + {% endfor %} + +
CommitStatusSucceededFailedPendingTime
{{ e.commit_short }}{{ e.status_text }}{{ e.succeeded }}{{ e.failed }}{{ e.pending }}{{ e.time }}
+{% endif %} +{% endblock %} diff --git a/crates/server/templates/login.html b/crates/server/templates/login.html new file mode 100644 index 0000000..3a2d609 --- /dev/null +++ b/crates/server/templates/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Login - FC CI{% endblock %} +{% block content %} +

Login

+
+ {% match error %} + {% when Some with (msg) %} +
{{ msg }}
+ {% when None %} + {% endmatch %} +
+
+ + +
+ +
+
+{% endblock %} diff --git a/crates/server/templates/project.html b/crates/server/templates/project.html new file mode 100644 index 0000000..a5736ef --- /dev/null +++ b/crates/server/templates/project.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} +{% block title %}{{ project.name }} - FC CI{% endblock %} +{% block auth %} +{% if !auth_name.is_empty() %} +{{ auth_name }} +
+{% else %} +Login +{% endif %} +{% endblock %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +

{{ project.name }}

+{% match project.description %} + {% when Some with (desc) %} +

{{ desc }}

+ {% when None %} +{% endmatch %} +

Repository: {{ project.repository_url }}

+

Created: {{ project.created_at.format("%Y-%m-%d %H:%M") }}

+ +{% if is_admin %} +
+ +
+{% endif %} + +

Jobsets

+ +{% if is_admin %} +
+ Add Jobset +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+{% endif %} + +{% if jobsets.is_empty() %} +

No jobsets configured.

+{% else %} + + + + + + {% for j in jobsets %} + + + + + + + + {% endfor %} + +
NameExpressionFlakeEnabledInterval
{{ j.name }}{{ j.nix_expression }}{% if j.flake_mode %}Yes{% else %}No{% endif %}{% if j.enabled %}Yes{% else %}No{% endif %}{{ j.check_interval }}s
+{% endif %} + +

Recent Evaluations

+{% if recent_evals.is_empty() %} +

No evaluations yet.

+{% else %} + + + + + + {% for e in recent_evals %} + + + + + + {% endfor %} + +
CommitStatusTime
{{ e.commit_short }}{{ e.status_text }}{{ e.time }}
+{% endif %} +{% endblock %} +{% block scripts %} +{% if is_admin %} + +{% endif %} +{% endblock %} diff --git a/crates/server/templates/projects.html b/crates/server/templates/projects.html new file mode 100644 index 0000000..a5c1a1e --- /dev/null +++ b/crates/server/templates/projects.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% block title %}Projects - FC CI{% endblock %} +{% block auth %} +{% if !auth_name.is_empty() %} +{{ auth_name }} +
+{% else %} +Login +{% endif %} +{% endblock %} +{% block content %} +

Projects

+ +{% if is_admin %} +
+ New Project +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+{% endif %} + +{% if projects.is_empty() %} +

No projects yet.

+{% else %} + + + + + + {% for p in projects %} + + + + + + {% endfor %} + +
NameRepositoryCreated
{{ p.name }}{{ p.repository_url }}{{ p.created_at.format("%Y-%m-%d %H:%M") }}
+{% endif %} + +{% endblock %} +{% block scripts %} +{% if is_admin %} + +{% endif %} +{% endblock %} diff --git a/crates/server/templates/queue.html b/crates/server/templates/queue.html new file mode 100644 index 0000000..4581741 --- /dev/null +++ b/crates/server/templates/queue.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Queue - FC CI{% endblock %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +

Build Queue

+ +

Running ({{ running_count }})

+{% if running_builds.is_empty() %} +

No builds currently running.

+{% else %} + + + + + + {% for b in running_builds %} + + + + + + {% endfor %} + +
JobSystemStarted
{{ b.job_name }}{{ b.system }}{{ b.created_at }}
+{% endif %} + +

Pending ({{ pending_count }})

+{% if pending_builds.is_empty() %} +

No builds pending.

+{% else %} + + + + + + {% for b in pending_builds %} + + + + + + {% endfor %} + +
JobSystemCreated
{{ b.job_name }}{{ b.system }}{{ b.created_at }}
+{% endif %} +{% endblock %} diff --git a/crates/server/tests/api_tests.rs b/crates/server/tests/api_tests.rs new file mode 100644 index 0000000..f5ebc2a --- /dev/null +++ b/crates/server/tests/api_tests.rs @@ -0,0 +1,777 @@ +//! Integration tests for API endpoints. +//! Requires TEST_DATABASE_URL to be set. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +async fn get_pool() -> Option { + let url = match std::env::var("TEST_DATABASE_URL") { + Ok(url) => url, + Err(_) => { + println!("Skipping API test: TEST_DATABASE_URL not set"); + return None; + } + }; + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .ok()?; + + sqlx::migrate!("../common/migrations") + .run(&pool) + .await + .ok()?; + + Some(pool) +} + +fn build_app(pool: sqlx::PgPool) -> axum::Router { + let config = fc_common::config::Config::default(); + let server_config = config.server.clone(); + let state = fc_server::state::AppState { + pool, + config, + sessions: std::sync::Arc::new(dashmap::DashMap::new()), + }; + fc_server::routes::router(state, &server_config) +} + +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, + config, + sessions: std::sync::Arc::new(dashmap::DashMap::new()), + }; + fc_server::routes::router(state, &server_config) +} + +// ---- Existing tests ---- + +#[tokio::test] +async fn test_health_endpoint() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + let response = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); + assert_eq!(json["database"], true); +} + +#[tokio::test] +async fn test_project_endpoints() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + // Create project + let create_body = serde_json::json!({ + "name": format!("api-test-{}", uuid::Uuid::new_v4()), + "repository_url": "https://github.com/test/repo", + "description": "Test project" + }); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/projects") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&create_body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let project: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let project_id = project["id"].as_str().unwrap(); + + // Get project + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/projects/{project_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // List projects + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/projects") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // Get non-existent project -> 404 + let fake_id = uuid::Uuid::new_v4(); + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/projects/{fake_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Delete project + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/v1/projects/{project_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_builds_endpoints() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + // Stats endpoint + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/builds/stats") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // Recent endpoint + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/builds/recent") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +// ---- Hardening tests ---- + +#[tokio::test] +async fn test_error_response_includes_error_code() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + let fake_id = uuid::Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/v1/projects/{fake_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["error_code"], "NOT_FOUND"); + assert!(json["error"].as_str().is_some()); +} + +#[tokio::test] +async fn test_cache_invalid_hash_returns_404() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let mut config = fc_common::config::Config::default(); + config.cache.enabled = true; + let app = build_app_with_config(pool, config); + + // Too short + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/tooshort.narinfo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Contains uppercase + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEF.narinfo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Contains special chars + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/abcdefghijklmnop!@#$%^&*()abcde.narinfo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // SQL injection attempt + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/'%20OR%201=1;%20DROP%20TABLE%20builds;--.narinfo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Valid hash format but no matching product -> 404 (not error) + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_cache_nar_invalid_hash_returns_404() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let mut config = fc_common::config::Config::default(); + config.cache.enabled = true; + let app = build_app_with_config(pool, config); + + // Invalid hash in NAR endpoint + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/nar/INVALID_HASH.nar.zst") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Invalid hash in uncompressed NAR endpoint + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/nar/INVALID_HASH.nar") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_cache_disabled_returns_404() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let mut config = fc_common::config::Config::default(); + config.cache.enabled = false; + let app = build_app_with_config(pool, config); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/nix-cache-info") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_search_rejects_long_query() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + // Query over 256 chars should return empty results + let long_query = "a".repeat(300); + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/search?q={long_query}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["projects"], serde_json::json!([])); + assert_eq!(json["builds"], serde_json::json!([])); +} + +#[tokio::test] +async fn test_search_rejects_empty_query() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/search?q=") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["projects"], serde_json::json!([])); + assert_eq!(json["builds"], serde_json::json!([])); +} + +#[tokio::test] +async fn test_search_whitespace_only_query() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/search?q=%20%20%20") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["projects"], serde_json::json!([])); +} + +#[tokio::test] +async fn test_builds_list_with_system_filter() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + // Filter by system - should return 200 even with no results + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/builds?system=x86_64-linux") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["items"].is_array()); + assert!(json["total"].is_number()); +} + +#[tokio::test] +async fn test_builds_list_with_job_name_filter() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/builds?job_name=hello") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["items"].is_array()); +} + +#[tokio::test] +async fn test_builds_list_combined_filters() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/builds?system=aarch64-linux&status=pending&job_name=foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_cache_info_returns_correct_headers() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let mut config = fc_common::config::Config::default(); + config.cache.enabled = true; + let app = build_app_with_config(pool, config); + + let response = app + .oneshot( + Request::builder() + .uri("/nix-cache/nix-cache-info") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "text/plain" + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains("StoreDir: /nix/store")); + assert!(body_str.contains("WantMassQuery: 1")); + assert!(body_str.contains("Priority: 30")); +} + +#[tokio::test] +async fn test_metrics_endpoint() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + let response = app + .oneshot( + Request::builder() + .uri("/metrics") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap() + .contains("text/plain") + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains("fc_builds_total")); + assert!(body_str.contains("fc_projects_total")); + assert!(body_str.contains("fc_evaluations_total")); +} + +#[tokio::test] +async fn test_get_nonexistent_build_returns_error_code() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + let fake_id = uuid::Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/v1/builds/{fake_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error_code"], "NOT_FOUND"); + assert!(json["error"].as_str().unwrap().contains("not found")); +} + +// ---- Validation tests ---- + +#[tokio::test] +async fn test_create_project_validation_rejects_invalid_name() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + // Name starting with dash + let body = serde_json::json!({ + "name": "-bad-name", + "repository_url": "https://github.com/test/repo" + }); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/projects") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error_code"], "VALIDATION_ERROR"); +} + +#[tokio::test] +async fn test_create_project_validation_rejects_bad_url() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + let body = serde_json::json!({ + "name": "valid-name", + "repository_url": "ftp://bad-protocol.com/repo" + }); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/projects") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error_code"], "VALIDATION_ERROR"); +} + +#[tokio::test] +async fn test_create_project_validation_accepts_valid() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + let app = build_app(pool); + + let body = serde_json::json!({ + "name": format!("valid-project-{}", uuid::Uuid::new_v4()), + "repository_url": "https://github.com/test/repo", + "description": "A valid project" + }); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/projects") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/crates/server/tests/e2e_test.rs b/crates/server/tests/e2e_test.rs new file mode 100644 index 0000000..80346b7 --- /dev/null +++ b/crates/server/tests/e2e_test.rs @@ -0,0 +1,334 @@ +//! End-to-end integration test. +//! Requires TEST_DATABASE_URL to be set. +//! Tests the full flow: create project -> jobset -> evaluation -> builds. +//! +//! Nix-dependent steps are skipped if nix is not available. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use fc_common::models::*; +use tower::ServiceExt; + +async fn get_pool() -> Option { + let url = match std::env::var("TEST_DATABASE_URL") { + Ok(url) => url, + Err(_) => { + println!("Skipping E2E test: TEST_DATABASE_URL not set"); + return None; + } + }; + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .ok()?; + + sqlx::migrate!("../common/migrations") + .run(&pool) + .await + .ok()?; + + Some(pool) +} + +#[tokio::test] +async fn test_e2e_project_eval_build_flow() { + let pool = match get_pool().await { + Some(p) => p, + None => return, + }; + + // 1. Create a project + let project_name = format!("e2e-test-{}", uuid::Uuid::new_v4()); + 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 { + project_id: project.id, + name: "default".to_string(), + nix_expression: "packages".to_string(), + enabled: Some(true), + flake_mode: Some(true), + check_interval: Some(300), + }, + ) + .await + .expect("create jobset"); + + assert_eq!(jobset.project_id, project.id); + assert!(jobset.enabled); + + // 3. Verify active jobsets include our new one + let active = fc_common::repo::jobsets::list_active(&pool) + .await + .expect("list active"); + assert!( + active.iter().any(|j| j.id == jobset.id), + "new jobset should be in active list" + ); + + // 4. Create an evaluation + let eval = fc_common::repo::evaluations::create( + &pool, + CreateEvaluation { + jobset_id: jobset.id, + commit_hash: "e2e0000000000000000000000000000000000000".to_string(), + }, + ) + .await + .expect("create evaluation"); + + assert_eq!(eval.jobset_id, jobset.id); + assert_eq!(eval.status, EvaluationStatus::Pending); + + // 5. Mark evaluation as running + 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 { + evaluation_id: eval.id, + job_name: "hello".to_string(), + drv_path: "/nix/store/e2e000-hello.drv".to_string(), + system: Some("x86_64-linux".to_string()), + 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 { + evaluation_id: eval.id, + job_name: "world".to_string(), + drv_path: "/nix/store/e2e000-world.drv".to_string(), + system: Some("x86_64-linux".to_string()), + outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-world"})), + is_aggregate: Some(false), + constituents: None, + }, + ) + .await + .expect("create build 2"); + + assert_eq!(build1.status, BuildStatus::Pending); + assert_eq!(build2.status, BuildStatus::Pending); + + // 7. Create build dependency (hello depends on world) + fc_common::repo::build_dependencies::create(&pool, build1.id, build2.id) + .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) + .await + .expect("check deps"); + assert!(!deps_complete, "deps should NOT be complete yet"); + + // 9. Complete build2 (world) + fc_common::repo::builds::start(&pool, build2.id) + .await + .expect("start build2"); + fc_common::repo::builds::complete( + &pool, + build2.id, + BuildStatus::Completed, + None, + Some("/nix/store/e2e000-world"), + None, + ) + .await + .expect("complete build2"); + + // 10. Now build1 deps should be complete + 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"); + + // 11. Complete build1 (hello) + fc_common::repo::builds::start(&pool, build1.id) + .await + .expect("start build1"); + + 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) + .await + .expect("complete step"); + + fc_common::repo::build_products::create( + &pool, + CreateBuildProduct { + build_id: build1.id, + name: "out".to_string(), + path: "/nix/store/e2e000-hello".to_string(), + sha256_hash: Some("abcdef1234567890".to_string()), + file_size: Some(12345), + content_type: None, + is_directory: true, + }, + ) + .await + .expect("create product"); + + fc_common::repo::builds::complete( + &pool, + build1.id, + BuildStatus::Completed, + None, + Some("/nix/store/e2e000-hello"), + None, + ) + .await + .expect("complete build1"); + + // 12. Mark evaluation as completed + fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Completed, None) + .await + .expect("complete eval"); + + // 13. Verify everything is in the expected state + let final_eval = fc_common::repo::evaluations::get(&pool, eval.id) + .await + .expect("get eval"); + assert_eq!(final_eval.status, EvaluationStatus::Completed); + + let final_build1 = fc_common::repo::builds::get(&pool, build1.id) + .await + .expect("get build1"); + assert_eq!(final_build1.status, BuildStatus::Completed); + assert_eq!( + final_build1.build_output_path.as_deref(), + Some("/nix/store/e2e000-hello") + ); + + let products = fc_common::repo::build_products::list_for_build(&pool, build1.id) + .await + .expect("list products"); + assert_eq!(products.len(), 1); + assert_eq!(products[0].name, "out"); + + let steps = fc_common::repo::build_steps::list_for_build(&pool, build1.id) + .await + .expect("list steps"); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].exit_code, Some(0)); + + // 14. Verify build stats reflect our changes + let stats = fc_common::repo::builds::get_stats(&pool) + .await + .expect("get stats"); + 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 { + project_id: project.id, + name: "stable".to_string(), + jobset_id: jobset.id, + }, + ) + .await + .expect("create channel"); + + let channels = fc_common::repo::channels::list_all(&pool) + .await + .expect("list channels"); + assert!(channels.iter().any(|c| c.id == channel.id)); + + // 16. Test the HTTP API layer + let config = fc_common::config::Config::default(); + let server_config = config.server.clone(); + let state = fc_server::state::AppState { + pool: pool.clone(), + config, + sessions: std::sync::Arc::new(dashmap::DashMap::new()), + }; + let app = fc_server::routes::router(state, &server_config); + + // GET /health + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // GET /api/v1/projects/{id} + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/projects/{}", project.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // GET /api/v1/builds/{id} + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/builds/{}", build1.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // GET / (dashboard) + let resp = app + .clone() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains("Dashboard")); + + // Clean up + let _ = fc_common::repo::projects::delete(&pool, project.id).await; +}