crates/server: REST API routes; RBAC auth middleware; cookie sessions; dashboard

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5298a925bd9c11780e49d8b1c98eebd86a6a6964
This commit is contained in:
raf 2026-02-01 15:13:33 +03:00
commit 235d3d38a6
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
38 changed files with 6275 additions and 7 deletions

View file

@ -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<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
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<AppState> for RequireAdmin {
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
let key = parts
.extensions
.get::<ApiKey>()
.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<ApiKey, StatusCode> {
let key = extensions
.get::<ApiKey>()
.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<AppState>,
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<String> {
header
.split(';')
.filter_map(|pair| {
let pair = pair.trim();
let (k, v) = pair.split_once('=')?;
if k.trim() == name {
Some(v.trim().to_string())
} else {
None
}
})
.next()
}

View file

@ -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<CiError> 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()
}
}

4
crates/server/src/lib.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod auth_middleware;
pub mod error;
pub mod routes;
pub mod state;

View file

@ -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<String>,
#[arg(short, long)]
port: Option<u16>,
}
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::<std::net::SocketAddr>();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("Server shutting down, closing database pool");
db.close().await;
Ok(())
}

View file

@ -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<AppState>,
) -> Result<Json<Vec<RemoteBuilder>>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<RemoteBuilder>, 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<AppState>,
Json(input): Json<CreateRemoteBuilder>,
) -> Result<Json<RemoteBuilder>, 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<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateRemoteBuilder>,
) -> Result<Json<RemoteBuilder>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
) -> Result<Json<SystemStatus>, 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<AppState> {
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))
}

View file

@ -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<String>,
}
#[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<chrono::Utc>,
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
}
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<AppState>,
Json(input): Json<CreateApiKeyRequest>,
) -> Result<Json<CreateApiKeyResponse>, 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<AppState>,
) -> Result<Json<Vec<ApiKeyInfo>>, ApiError> {
let keys = repo::api_keys::list(&state.pool).await.map_err(ApiError)?;
let infos: Vec<ApiKeyInfo> = 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<AppState>,
axum::extract::Path(id): axum::extract::Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
repo::api_keys::delete(&state.pool, id)
.await
.map_err(ApiError)?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api-keys", get(list_api_keys).post(create_api_key))
.route("/api-keys/{id}", axum::routing::delete(delete_api_key))
}

View file

@ -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<AppState>,
Path((project_name, jobset_name, job_name)): Path<(String, String, String)>,
) -> Result<Response, ApiError> {
// Find the project
let project = fc_common::repo::projects::get_by_name(&state.pool, &project_name)
.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<AppState>,
Path((project_name, jobset_name, job_name)): Path<(String, String, String)>,
) -> Result<Response, ApiError> {
let project = fc_common::repo::projects::get_by_name(&state.pool, &project_name)
.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!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_width}\" height=\"20\">\n"
));
svg.push_str(" <linearGradient id=\"b\" x2=\"0\" y2=\"100%\">\n");
svg.push_str(" <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n");
svg.push_str(" <stop offset=\"1\" stop-opacity=\".1\"/>\n");
svg.push_str(" </linearGradient>\n");
svg.push_str(" <mask id=\"a\">\n");
svg.push_str(&format!(
" <rect width=\"{total_width}\" height=\"20\" rx=\"3\" fill=\"#fff\"/>\n"
));
svg.push_str(" </mask>\n");
svg.push_str(" <g mask=\"url(#a)\">\n");
svg.push_str(&format!(
" <rect width=\"{subject_width}\" height=\"20\" fill=\"#555\"/>\n"
));
svg.push_str(&format!(
" <rect x=\"{subject_width}\" width=\"{status_width}\" height=\"20\" fill=\"{color}\"/>\n"
));
svg.push_str(&format!(
" <rect width=\"{total_width}\" height=\"20\" fill=\"url(#b)\"/>\n"
));
svg.push_str(" </g>\n");
svg.push_str(" <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n");
svg.push_str(&format!(
" <text x=\"{subject_x}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{subject}</text>\n"
));
svg.push_str(&format!(
" <text x=\"{subject_x}\" y=\"14\">{subject}</text>\n"
));
svg.push_str(&format!(
" <text x=\"{status_x}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{status}</text>\n"
));
svg.push_str(&format!(
" <text x=\"{status_x}\" y=\"14\">{status}</text>\n"
));
svg.push_str(" </g>\n");
svg.push_str("</svg>");
svg
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/job/{project}/{jobset}/{job}/shield", get(build_badge))
.route("/job/{project}/{jobset}/{job}/latest", get(latest_build))
}

View file

@ -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<Uuid>,
status: Option<String>,
system: Option<String>,
job_name: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn list_builds(
State(state): State<AppState>,
Query(params): Query<ListBuildsParams>,
) -> Result<Json<PaginatedResponse<Build>>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Build>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<Build>>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<BuildStep>>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<BuildProduct>>, 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<AppState>,
) -> Result<Json<fc_common::BuildStats>, 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<AppState>) -> Result<Json<Vec<Build>>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<Build>>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Build>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Build>, ApiError> {
check_role(&extensions, &["bump-to-front"])?;
let build = sqlx::query_as::<_, Build>(
"UPDATE builds SET priority = priority + 10 WHERE id = $1 AND status = 'pending' RETURNING *",
)
.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<AppState>,
Path((build_id, product_id)): Path<(Uuid, Uuid)>,
) -> Result<Response, ApiError> {
// 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<AppState> {
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))
}

View file

@ -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<AppState>,
Path(hash): Path<String>,
) -> Result<Response, ApiError> {
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::<serde_json::Value>(&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<String> = 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<AppState>,
Path(hash): Path<String>,
) -> Result<Response, ApiError> {
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<AppState>,
Path(hash): Path<String>,
) -> Result<Response, ApiError> {
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<AppState>,
path: Path<String>,
) -> Result<Response, ApiError> {
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<AppState>) -> 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<AppState> {
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))
}

View file

@ -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<AppState>) -> Result<Json<Vec<Channel>>, 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<AppState>,
Path(project_id): Path<Uuid>,
) -> Result<Json<Vec<Channel>>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Channel>, 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<AppState>,
Json(input): Json<CreateChannel>,
) -> Result<Json<Channel>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path((channel_id, eval_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Channel>, ApiError> {
let channel = fc_common::repo::channels::promote(&state.pool, channel_id, eval_id)
.await
.map_err(ApiError)?;
Ok(Json(channel))
}
pub fn router() -> Router<AppState> {
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),
)
}

View file

@ -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<String>,
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<chrono::Utc>>,
completed: Option<&chrono::DateTime<chrono::Utc>>,
) -> 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::<ApiKey>()
.map(|k| k.role == "admin")
.unwrap_or(false)
}
fn auth_name(extensions: &Extensions) -> String {
extensions
.get::<ApiKey>()
.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<BuildView>,
recent_evals: Vec<EvalView>,
projects: Vec<ProjectSummaryView>,
is_admin: bool,
auth_name: String,
}
#[derive(Template)]
#[template(path = "projects.html")]
struct ProjectsTemplate {
projects: Vec<Project>,
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<Jobset>,
recent_evals: Vec<EvalView>,
is_admin: bool,
auth_name: String,
}
#[derive(Template)]
#[template(path = "jobset.html")]
struct JobsetTemplate {
project: Project,
jobset: Jobset,
eval_summaries: Vec<EvalSummaryView>,
}
#[derive(Template)]
#[template(path = "evaluations.html")]
struct EvaluationsTemplate {
evals: Vec<EvalView>,
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<BuildView>,
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<BuildView>,
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<BuildStep>,
products: Vec<BuildProduct>,
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<BuildView>,
running_builds: Vec<BuildView>,
pending_count: i64,
running_count: i64,
}
#[derive(Template)]
#[template(path = "channels.html")]
struct ChannelsTemplate {
channels: Vec<Channel>,
}
#[derive(Template)]
#[template(path = "admin.html")]
struct AdminTemplate {
status: SystemStatus,
builders: Vec<RemoteBuilder>,
api_keys: Vec<ApiKeyView>,
is_admin: bool,
auth_name: String,
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate {
error: Option<String>,
}
// --- Handlers ---
async fn home(State(state): State<AppState>, extensions: Extensions) -> Html<String> {
let stats = fc_common::repo::builds::get_stats(&state.pool)
.await
.unwrap_or_default();
let builds = fc_common::repo::builds::list_recent(&state.pool, 10)
.await
.unwrap_or_default();
let evals = fc_common::repo::evaluations::list_filtered(&state.pool, None, None, 5, 0)
.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<Evaluation> = 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<i64>,
offset: Option<i64>,
}
async fn projects_page(
State(state): State<AppState>,
Query(params): Query<PageParams>,
extensions: Extensions,
) -> Html<String> {
let limit = params.limit.unwrap_or(50).min(200).max(1);
let offset = params.offset.unwrap_or(0).max(0);
let items = fc_common::repo::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<AppState>,
Path(id): Path<Uuid>,
extensions: Extensions,
) -> Html<String> {
let project = match fc_common::repo::projects::get(&state.pool, id).await {
Ok(p) => p,
Err(_) => return Html("Project not found".to_string()),
};
let 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<AppState>, Path(id): Path<Uuid>) -> Html<String> {
let jobset = match fc_common::repo::jobsets::get(&state.pool, id).await {
Ok(j) => j,
Err(_) => return Html("Jobset not found".to_string()),
};
let project = match fc_common::repo::projects::get(&state.pool, jobset.project_id).await {
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<AppState>,
Query(params): Query<PageParams>,
) -> Html<String> {
let limit = params.limit.unwrap_or(50).min(200).max(1);
let offset = params.offset.unwrap_or(0).max(0);
let items = fc_common::repo::evaluations::list_filtered(&state.pool, None, None, limit, offset)
.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<AppState>, Path(id): Path<Uuid>) -> Html<String> {
let eval = match fc_common::repo::evaluations::get(&state.pool, id).await {
Ok(e) => e,
Err(_) => return Html("Evaluation not found".to_string()),
};
let jobset = match fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await {
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<String>,
system: Option<String>,
job_name: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn builds_page(
State(state): State<AppState>,
Query(params): Query<BuildFilterParams>,
) -> Html<String> {
let limit = params.limit.unwrap_or(50).min(200).max(1);
let offset = params.offset.unwrap_or(0).max(0);
let items = fc_common::repo::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<AppState>, Path(id): Path<Uuid>) -> Html<String> {
let build = match fc_common::repo::builds::get(&state.pool, id).await {
Ok(b) => b,
Err(_) => return Html("Build not found".to_string()),
};
let eval = match fc_common::repo::evaluations::get(&state.pool, build.evaluation_id).await {
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<AppState>) -> Html<String> {
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<AppState>) -> Html<String> {
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<AppState>, extensions: Extensions) -> Html<String> {
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<ApiKeyView> = 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<String> {
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<AppState>, Form(form): Form<LoginForm>) -> Response {
let token = form.api_key.trim();
if token.is_empty() {
let tmpl = LoginTemplate {
error: Some("API key is required".to_string()),
};
return Html(
tmpl.render()
.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<AppState> {
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))
}

View file

@ -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<Uuid>,
status: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
async fn list_evaluations(
State(state): State<AppState>,
Query(params): Query<ListEvaluationsParams>,
) -> Result<Json<PaginatedResponse<Evaluation>>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Evaluation>, 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<AppState>,
Json(input): Json<CreateEvaluation>,
) -> Result<Json<Evaluation>, 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<AppState> {
Router::new()
.route("/evaluations", get(list_evaluations))
.route("/evaluations/{id}", get(get_evaluation))
.route("/evaluations/trigger", post(trigger_evaluation))
}

View file

@ -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<AppState>) -> Json<HealthResponse> {
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<AppState> {
Router::new().route("/health", get(health_check))
}

View file

@ -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<AppState>,
Path((_project_id, id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Jobset>, 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<AppState>,
Path((_project_id, id)): Path<(Uuid, Uuid)>,
Json(input): Json<UpdateJobset>,
) -> Result<Json<Jobset>, 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<AppState>,
Path((_project_id, id)): Path<(Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, ApiError> {
fc_common::repo::jobsets::delete(&state.pool, id)
.await
.map_err(ApiError)?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
pub fn router() -> Router<AppState> {
Router::new().route(
"/projects/{project_id}/jobsets/{id}",
get(get_jobset).put(update_jobset).delete(delete_jobset),
)
}

View file

@ -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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Response, ApiError> {
// 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>>, ApiError> {
let build = fc_common::repo::builds::get(&state.pool, id)
.await
.map_err(ApiError)?;
let log_storage = fc_common::log_storage::LogStorage::new(state.config.logs.log_dir.clone())
.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<AppState> {
Router::new()
.route("/builds/{id}/log", get(get_build_log))
.route("/builds/{id}/log/stream", get(stream_build_log))
}

View file

@ -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<AppState>) -> Response {
let stats = match fc_common::repo::builds::get_stats(&state.pool).await {
Ok(s) => s,
Err(_) => {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
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<f64>, Option<f64>, Option<f64>) =
sqlx::query_as(
"SELECT \
(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY \
EXTRACT(EPOCH FROM (completed_at - started_at)))), \
(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY \
EXTRACT(EPOCH FROM (completed_at - started_at)))), \
(PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY \
EXTRACT(EPOCH FROM (completed_at - started_at)))) \
FROM builds WHERE completed_at IS NOT NULL AND started_at IS NOT NULL",
)
.fetch_one(&state.pool)
.await
.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<AppState> {
Router::new().route("/metrics", get(prometheus_metrics))
}

View file

@ -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<IpAddr, Vec<Instant>>,
_rps: u64,
burst: u32,
last_cleanup: std::sync::atomic::AtomicU64,
}
async fn rate_limit_middleware(
ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
let state = request.extensions().get::<Arc<RateLimitState>>().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<HeaderValue> = 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)
}

View file

@ -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<AppState>,
Query(pagination): Query<PaginationParams>,
) -> Result<Json<PaginatedResponse<Project>>, 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<AppState>,
Json(input): Json<CreateProject>,
) -> Result<Json<Project>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Project>, 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<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateProject>,
) -> Result<Json<Project>, 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(id): Path<Uuid>,
Query(pagination): Query<PaginationParams>,
) -> Result<Json<PaginatedResponse<Jobset>>, ApiError> {
let limit = pagination.limit();
let offset = pagination.offset();
let items = fc_common::repo::jobsets::list_for_project(&state.pool, id, limit, offset)
.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<bool>,
flake_mode: Option<bool>,
check_interval: Option<i32>,
}
async fn create_project_jobset(
extensions: Extensions,
State(state): State<AppState>,
Path(project_id): Path<Uuid>,
Json(body): Json<CreateJobsetBody>,
) -> Result<Json<Jobset>, 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<AppState> {
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),
)
}

View file

@ -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<Project>,
builds: Vec<Build>,
}
async fn search(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Result<Json<SearchResults>, 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<AppState> {
Router::new().route("/search", get(search))
}

View file

@ -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<String>,
after: Option<String>,
repository: Option<GithubRepo>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct GithubRepo {
clone_url: Option<String>,
html_url: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct GiteaPushPayload {
#[serde(alias = "ref")]
git_ref: Option<String>,
after: Option<String>,
repository: Option<GiteaRepo>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct GiteaRepo {
clone_url: Option<String>,
html_url: Option<String>,
}
/// 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::<Sha256>::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<AppState>,
Path(project_id): Path<Uuid>,
headers: HeaderMap,
body: Bytes,
) -> Result<(StatusCode, Json<WebhookResponse>), ApiError> {
// Check webhook config exists
let webhook_config =
repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, "github")
.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<AppState>,
Path(project_id): Path<Uuid>,
headers: HeaderMap,
body: Bytes,
) -> Result<(StatusCode, Json<WebhookResponse>), 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<AppState> {
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),
)
}

View file

@ -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<DashMap<String, SessionData>>,
}