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)) }