chore: format with updated rustfmt and taplo rules

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie9ef5fc421fa20071946cf1073f7920c6a6a6964
This commit is contained in:
raf 2026-02-02 02:23:50 +03:00
commit c306383d27
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
72 changed files with 11217 additions and 10487 deletions

View file

@ -1,37 +1,37 @@
[package]
name = "fc-server"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
name = "fc-server"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tokio.workspace = true
axum.workspace = true
sqlx.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
tracing.workspace = true
anyhow.workspace = true
askama.workspace = true
askama_axum.workspace = true
async-stream.workspace = true
axum.workspace = true
axum-extra.workspace = true
chrono.workspace = true
clap.workspace = true
config.workspace = true
dashmap.workspace = true
futures.workspace = true
hex.workspace = true
hmac.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
thiserror.workspace = true
clap.workspace = true
config.workspace = true
tower-http.workspace = true
tower.workspace = true
sha2.workspace = true
hex.workspace = true
hmac.workspace = true
tokio-util.workspace = true
async-stream.workspace = true
futures.workspace = true
axum-extra.workspace = true
dashmap.workspace = true
askama.workspace = true
askama_axum.workspace = true
uuid.workspace = true
# Our crates
fc-common.workspace = true

View file

@ -1,76 +1,79 @@
use axum::{
extract::{FromRequestParts, Request, State},
http::{StatusCode, request::Parts},
middleware::Next,
response::Response,
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 or session cookie.
/// Keys use the format: `Bearer fc_xxxx`. Session cookies use `fc_session=<id>`.
/// Write endpoints (POST/PUT/DELETE/PATCH) require a valid key.
/// Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for dashboard admin UI).
/// Extract and validate an API key from the Authorization header or session
/// cookie. Keys use the format: `Bearer fc_xxxx`. Session cookies use
/// `fc_session=<id>`. Write endpoints (POST/PUT/DELETE/PATCH) require a valid
/// key. Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for
/// dashboard admin UI).
pub async fn require_api_key(
State(state): State<AppState>,
mut request: Request,
next: Next,
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 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 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 "));
let token = auth_header
.as_deref()
.and_then(|h| h.strip_prefix("Bearer "));
// Try Bearer token first
if let Some(token) = token {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let key_hash = hex::encode(hasher.finalize());
// Try Bearer token first
if let Some(token) = token {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let key_hash = hex::encode(hasher.finalize());
if let Ok(Some(api_key)) =
fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await
{
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;
});
if let Ok(Some(api_key)) =
fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await
{
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);
return Ok(next.run(request).await);
}
request.extensions_mut().insert(api_key);
return Ok(next.run(request).await);
}
}
// Fall back to session cookie (so dashboard JS fetches work)
if let Some(cookie_header) = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
&& let Some(session_id) = parse_cookie(cookie_header, "fc_session")
&& let Some(session) = state.sessions.get(&session_id)
&& session.created_at.elapsed() < std::time::Duration::from_secs(24 * 60 * 60) {
request.extensions_mut().insert(session.api_key.clone());
return Ok(next.run(request).await);
}
// Fall back to session cookie (so dashboard JS fetches work)
if let Some(cookie_header) = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
&& let Some(session_id) = parse_cookie(cookie_header, "fc_session")
&& let Some(session) = state.sessions.get(&session_id)
&& session.created_at.elapsed()
< std::time::Duration::from_secs(24 * 60 * 60)
{
request.extensions_mut().insert(session.api_key.clone());
return Ok(next.run(request).await);
}
// No valid auth found
if is_read {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
// No valid auth found
if is_read {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
/// Extractor that requires an authenticated admin user.
@ -78,84 +81,88 @@ pub async fn require_api_key(
pub struct RequireAdmin(pub ApiKey);
impl FromRequestParts<AppState> for RequireAdmin {
type Rejection = StatusCode;
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)
}
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.
/// 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)
}
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,
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())
&& let Some(session_id) = parse_cookie(cookie_header, "fc_session")
&& 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
if let Some(cookie_header) = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
&& let Some(session_id) = parse_cookie(cookie_header, "fc_session")
&& let Some(session) = state.sessions.get(&session_id)
{
// 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(header: &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()
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

@ -1,6 +1,6 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
http::StatusCode,
response::{IntoResponse, Response},
};
use fc_common::CiError;
use serde_json::json;
@ -8,75 +8,91 @@ use serde_json::json;
pub struct ApiError(pub CiError);
impl From<CiError> for ApiError {
fn from(err: CiError) -> Self {
ApiError(err)
}
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::NixEval(msg) => (
StatusCode::UNPROCESSABLE_ENTITY,
"NIX_EVAL_ERROR",
msg.clone(),
),
CiError::Build(msg) => (StatusCode::UNPROCESSABLE_ENTITY, "BUILD_ERROR", msg.clone()),
CiError::Config(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
"CONFIG_ERROR",
msg.clone(),
),
CiError::Database(e) => {
tracing::error!(error = %e, "Database error in API handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_ERROR",
"Internal database error".to_string(),
)
}
CiError::Git(e) => {
tracing::error!(error = %e, "Git error in API handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"GIT_ERROR",
format!("Git operation failed: {e}"),
)
}
CiError::Serialization(e) => {
tracing::error!(error = %e, "Serialization error in API handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"SERIALIZATION_ERROR",
format!("Data serialization error: {e}"),
)
}
CiError::Io(e) => {
tracing::error!(error = %e, "IO error in API handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"IO_ERROR",
format!("IO error: {e}"),
)
}
};
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::NixEval(msg) => {
(
StatusCode::UNPROCESSABLE_ENTITY,
"NIX_EVAL_ERROR",
msg.clone(),
)
},
CiError::Build(msg) => {
(StatusCode::UNPROCESSABLE_ENTITY, "BUILD_ERROR", msg.clone())
},
CiError::Config(msg) => {
(
StatusCode::INTERNAL_SERVER_ERROR,
"CONFIG_ERROR",
msg.clone(),
)
},
CiError::Database(e) => {
tracing::error!(error = %e, "Database error in API handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_ERROR",
"Internal database error".to_string(),
)
},
CiError::Git(e) => {
tracing::error!(error = %e, "Git error in API handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"GIT_ERROR",
format!("Git operation failed: {e}"),
)
},
CiError::Serialization(e) => {
tracing::error!(error = %e, "Serialization error in API handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"SERIALIZATION_ERROR",
format!("Data serialization error: {e}"),
)
},
CiError::Io(e) => {
tracing::error!(error = %e, "IO error in API handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"IO_ERROR",
format!("IO error: {e}"),
)
},
};
if status.is_server_error() {
tracing::warn!(
status = %status,
code = code,
"API error response: {}",
message
);
}
let body = axum::Json(json!({ "error": message, "error_code": code }));
(status, body).into_response()
if status.is_server_error() {
tracing::warn!(
status = %status,
code = code,
"API error response: {}",
message
);
}
let body = axum::Json(json!({ "error": message, "error_code": code }));
(status, body).into_response()
}
}

View file

@ -1,8 +1,6 @@
use fc_server::routes;
use fc_server::state;
use clap::Parser;
use fc_common::{Config, Database};
use fc_server::{routes, state};
use state::AppState;
use tokio::net::TcpListener;
@ -10,73 +8,73 @@ use tokio::net::TcpListener;
#[command(name = "fc-server")]
#[command(about = "CI Server - Web API and UI")]
struct Cli {
#[arg(short = 'H', long)]
host: Option<String>,
#[arg(short = 'H', long)]
host: Option<String>,
#[arg(short, long)]
port: Option<u16>,
#[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");
};
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(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::<()>();
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
tracing::info!("Shutdown signal received");
tracing::info!("Shutdown signal received");
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = Config::load()?;
fc_common::init_tracing(&config.tracing);
let config = Config::load()?;
fc_common::init_tracing(&config.tracing);
let cli = Cli::parse();
let cli = Cli::parse();
let host = cli.host.unwrap_or(config.server.host.clone());
let port = cli.port.unwrap_or(config.server.port);
let host = cli.host.unwrap_or(config.server.host.clone());
let port = cli.port.unwrap_or(config.server.port);
let db = Database::new(config.database.clone()).await?;
let db = Database::new(config.database.clone()).await?;
// Bootstrap declarative projects, jobsets, and API keys from config
fc_common::bootstrap::run(db.pool(), &config.declarative).await?;
// Bootstrap declarative projects, jobsets, and API keys from config
fc_common::bootstrap::run(db.pool(), &config.declarative).await?;
let state = AppState {
pool: db.pool().clone(),
config: config.clone(),
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
};
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 app = routes::router(state, &config.server);
let bind_addr = format!("{host}:{port}");
tracing::info!("Starting CI Server on {}", bind_addr);
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?;
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;
tracing::info!("Server shutting down, closing database pool");
db.close().await;
Ok(())
Ok(())
}

View file

@ -1,125 +1,132 @@
use axum::{
Json, Router,
extract::{Path, State},
routing::get,
Json,
Router,
extract::{Path, State},
routing::get,
};
use fc_common::{
Validate,
models::{
CreateRemoteBuilder,
RemoteBuilder,
SystemStatus,
UpdateRemoteBuilder,
},
};
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;
use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState};
async fn list_builders(
State(state): State<AppState>,
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))
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>,
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))
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>,
_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))
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>,
_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))
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>,
_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})))
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>,
_auth: RequireAdmin,
State(state): State<AppState>,
) -> Result<Json<SystemStatus>, ApiError> {
let pool = &state.pool;
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 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 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)))?;
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,
}))
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))
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

@ -4,95 +4,96 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::auth_middleware::RequireAdmin;
use crate::error::ApiError;
use crate::state::AppState;
use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState};
#[derive(Debug, Deserialize)]
pub struct CreateApiKeyRequest {
pub name: String,
pub role: Option<String>,
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,
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 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())
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>,
_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());
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);
// 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)?;
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,
}))
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>,
_auth: RequireAdmin,
State(state): State<AppState>,
) -> Result<Json<Vec<ApiKeyInfo>>, ApiError> {
let keys = repo::api_keys::list(&state.pool).await.map_err(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();
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))
Ok(Json(infos))
}
async fn delete_api_key(
_auth: RequireAdmin,
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<Uuid>,
_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 })))
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))
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

@ -1,171 +1,202 @@
use axum::{
Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use crate::error::ApiError;
use crate::state::AppState;
use crate::{error::ApiError, state::AppState};
async fn build_badge(
State(state): State<AppState>,
Path((project_name, jobset_name, job_name)): Path<(String, String, String)>,
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 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)?;
// 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());
}
};
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)?;
// 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());
}
};
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)?;
// 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 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"),
};
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),
Ok(
(
StatusCode::OK,
[
("content-type", "image/svg+xml"),
("cache-control", "no-cache, no-store, must-revalidate"),
],
shield_svg("build", label, color),
)
.into_response())
.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)>,
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 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 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 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 = 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 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 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()),
}
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 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
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))
Router::new()
.route("/job/{project}/{jobset}/{job}/shield", get(build_badge))
.route("/job/{project}/{jobset}/{job}/latest", get(latest_build))
}

View file

@ -1,305 +1,321 @@
use axum::{
Json, Router,
body::Body,
extract::{Path, Query, State},
http::{Extensions, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Json,
Router,
body::Body,
extract::{Path, Query, State},
http::{Extensions, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
};
use fc_common::{
Build,
BuildProduct,
BuildStep,
PaginatedResponse,
PaginationParams,
};
use fc_common::{Build, BuildProduct, BuildStep, PaginatedResponse, PaginationParams};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth_middleware::RequireRoles;
use crate::error::ApiError;
use crate::state::AppState;
use crate::{auth_middleware::RequireRoles, error::ApiError, state::AppState};
fn check_role(extensions: &Extensions, allowed: &[&str]) -> Result<(), ApiError> {
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())
})
})
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>,
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>,
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,
}))
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>,
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))
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>,
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))
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>,
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))
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>,
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))
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>,
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))
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 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>,
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))
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>,
extensions: Extensions,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Build>, ApiError> {
check_role(&extensions, &["restart-jobs"])?;
let build = fc_common::repo::builds::restart(&state.pool, id)
.await
.map_err(ApiError)?;
check_role(&extensions, &["restart-jobs"])?;
let build = fc_common::repo::builds::restart(&state.pool, id)
.await
.map_err(ApiError)?;
tracing::info!(
build_id = %id,
job = %build.job_name,
"Build restarted"
);
tracing::info!(
build_id = %id,
job = %build.job_name,
"Build restarted"
);
Ok(Json(build))
Ok(Json(build))
}
async fn bump_build(
extensions: Extensions,
State(state): State<AppState>,
Path(id): Path<Uuid>,
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(),
))
})?;
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))
Ok(Json(build))
}
async fn download_build_product(
State(state): State<AppState>,
Path((build_id, product_id)): Path<(Uuid, Uuid)>,
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)?;
// 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)?;
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 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(),
)));
}
},
};
if !fc_common::validate::is_valid_store_path(&product.path) {
return Err(ApiError(fc_common::CiError::Validation(
"Invalid store path".to_string(),
)));
}
let stream = tokio_util::io::ReaderStream::new(stdout);
let body = Body::from_stream(stream);
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 filename = product.path.rsplit('/').next().unwrap_or(&product.name);
let mut child = match child {
Ok(c) => c,
Err(e) => {
return Err(ApiError(fc_common::CiError::Build(format!(
"Failed to dump path: {e}"
))));
}
};
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 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(file);
let body = Body::from_stream(stream);
let stream = tokio_util::io::ReaderStream::new(stdout);
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);
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())
}
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))
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

@ -1,365 +1,369 @@
use axum::{
Router,
body::Body,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
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;
use crate::{error::ApiError, 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>,
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<Response, ApiError> {
if !state.config.cache.enabled {
return Ok(StatusCode::NOT_FOUND.into_response());
}
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);
// 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());
}
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)))?;
// 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()),
};
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());
}
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;
// 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 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 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 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 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();
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 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());
// Extract content-addressable hash
let ca = entry.get("ca").and_then(|v| v.as_str());
let file_hash = nar_hash;
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(" "),
);
let mut narinfo_text = format!(
"StorePath: {store_path}\nURL: nar/{hash}.nar.zst\nCompression: \
zstd\nFileHash: {file_hash}\nFileSize: {nar_size}\nNarHash: \
{nar_hash}\nNarSize: {nar_size}\nReferences: {refs}\n",
store_path = store_path,
hash = hash,
file_hash = file_hash,
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"));
}
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 {
// 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,
Ok(
(
StatusCode::OK,
[("content-type", "text/x-nix-narinfo")],
narinfo_text,
)
.into_response())
.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 = 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 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,
])
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;
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
&& let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&o.stdout)
&& 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()
if let Ok(o) = re_output
&& let Ok(parsed) =
serde_json::from_slice::<serde_json::Value>(&o.stdout)
&& 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()
},
_ => 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>,
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<Response, ApiError> {
if !state.config.cache.enabled {
return Ok(StatusCode::NOT_FOUND.into_response());
}
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);
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());
}
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 = 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()),
};
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());
}
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(),
))
})?;
// 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 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 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 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);
let stream = tokio_util::io::ReaderStream::new(zstd_stdout);
let body = Body::from_stream(stream);
Ok((StatusCode::OK, [("content-type", "application/zstd")], body).into_response())
Ok(
(StatusCode::OK, [("content-type", "application/zstd")], body)
.into_response(),
)
}
/// Serve an uncompressed NAR file for a store path (legacy).
/// GET /nix-cache/nar/{hash}.nar
async fn serve_nar(
State(state): State<AppState>,
Path(hash): Path<String>,
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Result<Response, ApiError> {
if !state.config.cache.enabled {
return Ok(StatusCode::NOT_FOUND.into_response());
}
if !state.config.cache.enabled {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let hash = hash.strip_suffix(".nar").unwrap_or(&hash);
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());
}
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",
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,
)
.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())
.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>,
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())
}
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();
}
if !state.config.cache.enabled {
return StatusCode::NOT_FOUND.into_response();
}
let info = "StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 30\n";
let info = "StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 30\n";
(StatusCode::OK, [("content-type", "text/plain")], info).into_response()
(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))
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

@ -1,89 +1,94 @@
use axum::{
Json, Router,
extract::{Path, State},
routing::{get, post},
Json,
Router,
extract::{Path, State},
routing::{get, post},
};
use fc_common::{
Validate,
models::{Channel, CreateChannel},
};
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;
use crate::{auth_middleware::RequireAdmin, error::ApiError, 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_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>,
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))
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>,
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))
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>,
_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))
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>,
_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})))
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)>,
_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))
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),
)
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),
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,212 +1,220 @@
use axum::{
Json, Router,
extract::{Path, Query, State},
http::Extensions,
routing::{get, post},
};
use fc_common::{CreateEvaluation, Evaluation, PaginatedResponse, PaginationParams, Validate};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use axum::{
Json,
Router,
extract::{Path, Query, State},
http::Extensions,
routing::{get, post},
};
use fc_common::{
CreateEvaluation,
Evaluation,
PaginatedResponse,
PaginationParams,
Validate,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth_middleware::RequireRoles;
use crate::error::ApiError;
use crate::state::AppState;
use crate::{auth_middleware::RequireRoles, error::ApiError, state::AppState};
#[derive(Debug, Deserialize)]
struct ListEvaluationsParams {
jobset_id: Option<Uuid>,
status: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
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>,
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,
}))
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>,
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))
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>,
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))
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))
}
#[derive(Debug, Deserialize)]
struct CompareParams {
to: Uuid,
to: Uuid,
}
#[derive(Debug, Serialize)]
struct EvalComparison {
from_id: Uuid,
to_id: Uuid,
new_jobs: Vec<JobDiff>,
removed_jobs: Vec<JobDiff>,
changed_jobs: Vec<JobChange>,
unchanged_count: usize,
from_id: Uuid,
to_id: Uuid,
new_jobs: Vec<JobDiff>,
removed_jobs: Vec<JobDiff>,
changed_jobs: Vec<JobChange>,
unchanged_count: usize,
}
#[derive(Debug, Serialize)]
struct JobDiff {
job_name: String,
system: Option<String>,
drv_path: String,
status: String,
job_name: String,
system: Option<String>,
drv_path: String,
status: String,
}
#[derive(Debug, Serialize)]
struct JobChange {
job_name: String,
system: Option<String>,
old_drv: String,
new_drv: String,
old_status: String,
new_status: String,
job_name: String,
system: Option<String>,
old_drv: String,
new_drv: String,
old_status: String,
new_status: String,
}
async fn compare_evaluations(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(params): Query<CompareParams>,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(params): Query<CompareParams>,
) -> Result<Json<EvalComparison>, ApiError> {
// Verify both evaluations exist
let _from_eval = fc_common::repo::evaluations::get(&state.pool, id)
.await
.map_err(ApiError)?;
let _to_eval = fc_common::repo::evaluations::get(&state.pool, params.to)
.await
.map_err(ApiError)?;
// Verify both evaluations exist
let _from_eval = fc_common::repo::evaluations::get(&state.pool, id)
.await
.map_err(ApiError)?;
let _to_eval = fc_common::repo::evaluations::get(&state.pool, params.to)
.await
.map_err(ApiError)?;
let from_builds = fc_common::repo::builds::list_for_evaluation(&state.pool, id)
.await
.map_err(ApiError)?;
let to_builds = fc_common::repo::builds::list_for_evaluation(&state.pool, params.to)
.await
.map_err(ApiError)?;
let from_builds =
fc_common::repo::builds::list_for_evaluation(&state.pool, id)
.await
.map_err(ApiError)?;
let to_builds =
fc_common::repo::builds::list_for_evaluation(&state.pool, params.to)
.await
.map_err(ApiError)?;
let from_map: HashMap<&str, &fc_common::Build> = from_builds
.iter()
.map(|b| (b.job_name.as_str(), b))
.collect();
let to_map: HashMap<&str, &fc_common::Build> =
to_builds.iter().map(|b| (b.job_name.as_str(), b)).collect();
let from_map: HashMap<&str, &fc_common::Build> = from_builds
.iter()
.map(|b| (b.job_name.as_str(), b))
.collect();
let to_map: HashMap<&str, &fc_common::Build> =
to_builds.iter().map(|b| (b.job_name.as_str(), b)).collect();
let mut new_jobs = Vec::new();
let mut removed_jobs = Vec::new();
let mut changed_jobs = Vec::new();
let mut unchanged_count = 0;
let mut new_jobs = Vec::new();
let mut removed_jobs = Vec::new();
let mut changed_jobs = Vec::new();
let mut unchanged_count = 0;
// Jobs in `to` but not in `from` are new
for (name, build) in &to_map {
if !from_map.contains_key(name) {
new_jobs.push(JobDiff {
job_name: name.to_string(),
system: build.system.clone(),
drv_path: build.drv_path.clone(),
status: format!("{:?}", build.status),
});
}
// Jobs in `to` but not in `from` are new
for (name, build) in &to_map {
if !from_map.contains_key(name) {
new_jobs.push(JobDiff {
job_name: name.to_string(),
system: build.system.clone(),
drv_path: build.drv_path.clone(),
status: format!("{:?}", build.status),
});
}
}
// Jobs in `from` but not in `to` are removed
for (name, build) in &from_map {
if !to_map.contains_key(name) {
removed_jobs.push(JobDiff {
job_name: name.to_string(),
system: build.system.clone(),
drv_path: build.drv_path.clone(),
status: format!("{:?}", build.status),
});
}
// Jobs in `from` but not in `to` are removed
for (name, build) in &from_map {
if !to_map.contains_key(name) {
removed_jobs.push(JobDiff {
job_name: name.to_string(),
system: build.system.clone(),
drv_path: build.drv_path.clone(),
status: format!("{:?}", build.status),
});
}
}
// Jobs in both: compare derivation paths
for (name, from_build) in &from_map {
if let Some(to_build) = to_map.get(name) {
if from_build.drv_path != to_build.drv_path {
changed_jobs.push(JobChange {
job_name: name.to_string(),
system: to_build.system.clone(),
old_drv: from_build.drv_path.clone(),
new_drv: to_build.drv_path.clone(),
old_status: format!("{:?}", from_build.status),
new_status: format!("{:?}", to_build.status),
});
} else {
unchanged_count += 1;
}
}
// Jobs in both: compare derivation paths
for (name, from_build) in &from_map {
if let Some(to_build) = to_map.get(name) {
if from_build.drv_path != to_build.drv_path {
changed_jobs.push(JobChange {
job_name: name.to_string(),
system: to_build.system.clone(),
old_drv: from_build.drv_path.clone(),
new_drv: to_build.drv_path.clone(),
old_status: format!("{:?}", from_build.status),
new_status: format!("{:?}", to_build.status),
});
} else {
unchanged_count += 1;
}
}
}
Ok(Json(EvalComparison {
from_id: id,
to_id: params.to,
new_jobs,
removed_jobs,
changed_jobs,
unchanged_count,
}))
Ok(Json(EvalComparison {
from_id: id,
to_id: params.to,
new_jobs,
removed_jobs,
changed_jobs,
unchanged_count,
}))
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/evaluations", get(list_evaluations))
.route("/evaluations/{id}", get(get_evaluation))
.route("/evaluations/{id}/compare", get(compare_evaluations))
.route("/evaluations/trigger", post(trigger_evaluation))
Router::new()
.route("/evaluations", get(list_evaluations))
.route("/evaluations/{id}", get(get_evaluation))
.route("/evaluations/{id}/compare", get(compare_evaluations))
.route("/evaluations/trigger", post(trigger_evaluation))
}

View file

@ -5,24 +5,24 @@ use crate::state::AppState;
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
database: bool,
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 db_ok = sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.pool)
.await
.is_ok();
let status = if db_ok { "ok" } else { "degraded" };
let status = if db_ok { "ok" } else { "degraded" };
Json(HealthResponse {
status,
database: db_ok,
})
Json(HealthResponse {
status,
database: db_ok,
})
}
pub fn router() -> Router<AppState> {
Router::new().route("/health", get(health_check))
Router::new().route("/health", get(health_check))
}

View file

@ -1,114 +1,114 @@
use axum::{
Json, Router,
extract::{Path, State},
routing::get,
Json,
Router,
extract::{Path, State},
routing::get,
};
use fc_common::{Jobset, JobsetInput, UpdateJobset, Validate};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth_middleware::RequireAdmin;
use crate::error::ApiError;
use crate::state::AppState;
use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState};
async fn get_jobset(
State(state): State<AppState>,
Path((_project_id, id)): Path<(Uuid, Uuid)>,
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))
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>,
_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))
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)>,
_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 })))
fc_common::repo::jobsets::delete(&state.pool, id)
.await
.map_err(ApiError)?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
// --- Jobset input routes ---
async fn list_jobset_inputs(
State(state): State<AppState>,
Path((_project_id, jobset_id)): Path<(Uuid, Uuid)>,
State(state): State<AppState>,
Path((_project_id, jobset_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Vec<JobsetInput>>, ApiError> {
let inputs = fc_common::repo::jobset_inputs::list_for_jobset(&state.pool, jobset_id)
.await
.map_err(ApiError)?;
Ok(Json(inputs))
let inputs =
fc_common::repo::jobset_inputs::list_for_jobset(&state.pool, jobset_id)
.await
.map_err(ApiError)?;
Ok(Json(inputs))
}
#[derive(Debug, Deserialize)]
struct CreateJobsetInputRequest {
name: String,
input_type: String,
value: String,
revision: Option<String>,
name: String,
input_type: String,
value: String,
revision: Option<String>,
}
async fn create_jobset_input(
_auth: RequireAdmin,
State(state): State<AppState>,
Path((_project_id, jobset_id)): Path<(Uuid, Uuid)>,
Json(body): Json<CreateJobsetInputRequest>,
_auth: RequireAdmin,
State(state): State<AppState>,
Path((_project_id, jobset_id)): Path<(Uuid, Uuid)>,
Json(body): Json<CreateJobsetInputRequest>,
) -> Result<Json<JobsetInput>, ApiError> {
let input = fc_common::repo::jobset_inputs::create(
&state.pool,
jobset_id,
&body.name,
&body.input_type,
&body.value,
body.revision.as_deref(),
)
.await
.map_err(ApiError)?;
Ok(Json(input))
let input = fc_common::repo::jobset_inputs::create(
&state.pool,
jobset_id,
&body.name,
&body.input_type,
&body.value,
body.revision.as_deref(),
)
.await
.map_err(ApiError)?;
Ok(Json(input))
}
async fn delete_jobset_input(
_auth: RequireAdmin,
State(state): State<AppState>,
Path((_project_id, _jobset_id, input_id)): Path<(Uuid, Uuid, Uuid)>,
_auth: RequireAdmin,
State(state): State<AppState>,
Path((_project_id, _jobset_id, input_id)): Path<(Uuid, Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, ApiError> {
fc_common::repo::jobset_inputs::delete(&state.pool, input_id)
.await
.map_err(ApiError)?;
Ok(Json(serde_json::json!({ "deleted": true })))
fc_common::repo::jobset_inputs::delete(&state.pool, input_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),
)
.route(
"/projects/{project_id}/jobsets/{jobset_id}/inputs",
get(list_jobset_inputs).post(create_jobset_input),
)
.route(
"/projects/{project_id}/jobsets/{jobset_id}/inputs/{input_id}",
axum::routing::delete(delete_jobset_input),
)
Router::new()
.route(
"/projects/{project_id}/jobsets/{id}",
get(get_jobset).put(update_jobset).delete(delete_jobset),
)
.route(
"/projects/{project_id}/jobsets/{jobset_id}/inputs",
get(list_jobset_inputs).post(create_jobset_input),
)
.route(
"/projects/{project_id}/jobsets/{jobset_id}/inputs/{input_id}",
axum::routing::delete(delete_jobset_input),
)
}

View file

@ -1,125 +1,142 @@
use axum::response::sse::{Event, KeepAlive};
use axum::{
Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response, Sse},
routing::get,
Router,
extract::{Path, State},
http::StatusCode,
response::{
IntoResponse,
Response,
Sse,
sse::{Event, KeepAlive},
},
routing::get,
};
use uuid::Uuid;
use crate::error::ApiError;
use crate::state::AppState;
use crate::{error::ApiError, state::AppState};
async fn get_build_log(
State(state): State<AppState>,
Path(id): Path<Uuid>,
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)?;
// 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)))?;
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,
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))),
}
.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)?;
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 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 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};
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() }
};
// 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 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;
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
&& 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,
}
}
};
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
&& 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()))
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))
Router::new()
.route("/builds/{id}/log", get(get_build_log))
.route("/builds/{id}/log/stream", get(stream_build_log))
}

View file

@ -1,188 +1,198 @@
use axum::{
Router,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
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 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
let eval_count: i64 =
match sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM evaluations")
.fetch_one(&state.pool)
.await
{
Ok(row) => row.0,
Err(_) => 0,
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 eval_by_status: Vec<(String, i64)> = sqlx::query_as(
"SELECT status::text, COUNT(*) FROM evaluations GROUP BY status",
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let (project_count, channel_count, builder_count): (i64, i64, i64) = sqlx::query_as(
"SELECT \
(SELECT COUNT(*) FROM projects), \
(SELECT COUNT(*) FROM channels), \
(SELECT COUNT(*) FROM remote_builders WHERE enabled = true)",
let (project_count, channel_count, builder_count): (i64, i64, i64) =
sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM projects), (SELECT COUNT(*) FROM \
channels), (SELECT COUNT(*) FROM remote_builders WHERE enabled = true)",
)
.fetch_one(&state.pool)
.await
.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();
// 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));
// 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();
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 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)
));
// 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"
));
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"
));
}
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"
));
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"
));
}
}
// 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()
(
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))
Router::new().route("/metrics", get(prometheus_metrics))
}

View file

@ -14,111 +14,115 @@ pub mod projects;
pub mod search;
pub mod webhooks;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Instant;
use std::{net::IpAddr, sync::Arc, time::Instant};
use axum::Router;
use axum::extract::ConnectInfo;
use axum::http::{HeaderValue, Request, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{
Router,
body::Body,
extract::ConnectInfo,
http::{HeaderValue, Request, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
};
use dashmap::DashMap;
use fc_common::config::ServerConfig;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
use tower_http::{
cors::{AllowOrigin, CorsLayer},
limit::RequestBodyLimitLayer,
set_header::SetResponseHeaderLayer,
trace::TraceLayer,
};
use axum::body::Body;
use axum::http::header;
use crate::auth_middleware::{extract_session, require_api_key};
use crate::state::AppState;
use crate::{
auth_middleware::{extract_session, require_api_key},
state::AppState,
};
static STYLE_CSS: &str = include_str!("../../static/style.css");
struct RateLimitState {
requests: DashMap<IpAddr, Vec<Instant>>,
_rps: u64,
burst: u32,
last_cleanup: std::sync::atomic::AtomicU64,
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,
ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
let state = request.extensions().get::<Arc<RateLimitState>>().cloned();
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);
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
&& 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);
// 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
&& 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()
});
}
next.run(request).await
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
}
async fn serve_style_css() -> Response {
Response::builder()
.header(header::CONTENT_TYPE, "text/css")
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(STYLE_CSS))
.unwrap()
.into_response()
Response::builder()
.header(header::CONTENT_TYPE, "text/css")
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(STYLE_CSS))
.unwrap()
.into_response()
}
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 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()
let mut app = Router::new()
// Static assets
.route("/static/style.css", get(serve_style_css))
// Dashboard routes with session extraction middleware
@ -169,18 +173,20 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router {
HeaderValue::from_static("strict-origin-when-cross-origin"),
));
// 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));
}
// 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)
app.with_state(state)
}

View file

@ -1,259 +1,270 @@
use axum::{
Json, Router,
extract::{Path, Query, State},
http::Extensions,
routing::{get, post},
Json,
Router,
extract::{Path, Query, State},
http::Extensions,
routing::{get, post},
};
use fc_common::nix_probe;
use fc_common::{
CreateJobset, CreateProject, Jobset, PaginatedResponse, PaginationParams, Project,
UpdateProject, Validate,
CreateJobset,
CreateProject,
Jobset,
PaginatedResponse,
PaginationParams,
Project,
UpdateProject,
Validate,
nix_probe,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth_middleware::{RequireAdmin, RequireRoles};
use crate::error::ApiError;
use crate::state::AppState;
use crate::{
auth_middleware::{RequireAdmin, RequireRoles},
error::ApiError,
state::AppState,
};
async fn list_projects(
State(state): State<AppState>,
Query(pagination): Query<PaginationParams>,
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,
}))
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>,
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))
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>,
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))
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>,
_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))
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>,
_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 })))
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>,
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,
}))
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>,
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>,
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,
branch: None,
scheduling_shares: None,
};
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))
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,
branch: None,
scheduling_shares: None,
};
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))
}
#[derive(Debug, Deserialize)]
struct ProbeRequest {
repository_url: String,
revision: Option<String>,
repository_url: String,
revision: Option<String>,
}
async fn probe_repository(
_extensions: Extensions,
Json(body): Json<ProbeRequest>,
_extensions: Extensions,
Json(body): Json<ProbeRequest>,
) -> Result<Json<nix_probe::FlakeProbeResult>, ApiError> {
let result = nix_probe::probe_flake(&body.repository_url, body.revision.as_deref())
.await
.map_err(ApiError)?;
Ok(Json(result))
let result =
nix_probe::probe_flake(&body.repository_url, body.revision.as_deref())
.await
.map_err(ApiError)?;
Ok(Json(result))
}
#[derive(Debug, Deserialize)]
struct SetupJobsetInput {
name: String,
nix_expression: String,
#[allow(dead_code)]
description: Option<String>,
name: String,
nix_expression: String,
#[allow(dead_code)]
description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SetupProjectRequest {
repository_url: String,
name: String,
description: Option<String>,
jobsets: Vec<SetupJobsetInput>,
repository_url: String,
name: String,
description: Option<String>,
jobsets: Vec<SetupJobsetInput>,
}
#[derive(serde::Serialize)]
struct SetupProjectResponse {
project: Project,
jobsets: Vec<Jobset>,
project: Project,
jobsets: Vec<Jobset>,
}
async fn setup_project(
extensions: Extensions,
State(state): State<AppState>,
Json(body): Json<SetupProjectRequest>,
extensions: Extensions,
State(state): State<AppState>,
Json(body): Json<SetupProjectRequest>,
) -> Result<Json<SetupProjectResponse>, 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())
})
})?;
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 create_project = CreateProject {
name: body.name,
repository_url: body.repository_url,
description: body.description,
let create_project = CreateProject {
name: body.name,
repository_url: body.repository_url,
description: body.description,
};
create_project
.validate()
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
let project = fc_common::repo::projects::create(&state.pool, create_project)
.await
.map_err(ApiError)?;
let mut jobsets = Vec::new();
for js_input in body.jobsets {
let input = CreateJobset {
project_id: project.id,
name: js_input.name,
nix_expression: js_input.nix_expression,
enabled: Some(true),
flake_mode: Some(true),
check_interval: None,
branch: None,
scheduling_shares: None,
};
create_project
.validate()
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
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)?;
jobsets.push(jobset);
}
let project = fc_common::repo::projects::create(&state.pool, create_project)
.await
.map_err(ApiError)?;
let mut jobsets = Vec::new();
for js_input in body.jobsets {
let input = CreateJobset {
project_id: project.id,
name: js_input.name,
nix_expression: js_input.nix_expression,
enabled: Some(true),
flake_mode: Some(true),
check_interval: None,
branch: None,
scheduling_shares: None,
};
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)?;
jobsets.push(jobset);
}
Ok(Json(SetupProjectResponse { project, jobsets }))
Ok(Json(SetupProjectResponse { project, jobsets }))
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/projects", get(list_projects).post(create_project))
.route("/projects/probe", post(probe_repository))
.route("/projects/setup", post(setup_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),
)
Router::new()
.route("/projects", get(list_projects).post(create_project))
.route("/projects/probe", post(probe_repository))
.route("/projects/setup", post(setup_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

@ -1,58 +1,60 @@
use axum::{
Json, Router,
extract::{Query, State},
routing::get,
Json,
Router,
extract::{Query, State},
routing::get,
};
use fc_common::models::{Build, Project};
use serde::{Deserialize, Serialize};
use crate::error::ApiError;
use crate::state::AppState;
use crate::{error::ApiError, state::AppState};
#[derive(Debug, Deserialize)]
struct SearchParams {
q: String,
q: String,
}
#[derive(Debug, Serialize)]
struct SearchResults {
projects: Vec<Project>,
builds: Vec<Build>,
projects: Vec<Project>,
builds: Vec<Build>,
}
async fn search(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
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 query = params.q.trim();
if query.is_empty() || query.len() > 256 {
return Ok(Json(SearchResults {
projects: vec![],
builds: vec![],
}));
}
let pattern = format!("%{query}%");
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 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)))?;
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 }))
Ok(Json(SearchResults { projects, builds }))
}
pub fn router() -> Router<AppState> {
Router::new().route("/search", get(search))
Router::new().route("/search", get(search))
}

View file

@ -1,302 +1,313 @@
use axum::{
Json, Router,
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::post,
Json,
Router,
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::post,
};
use fc_common::models::CreateEvaluation;
use fc_common::repo;
use fc_common::{models::CreateEvaluation, repo};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::ApiError;
use crate::state::AppState;
use crate::{error::ApiError, state::AppState};
#[derive(Debug, Serialize)]
struct WebhookResponse {
accepted: bool,
message: String,
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>,
#[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>,
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>,
#[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>,
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;
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);
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);
// 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;
};
let Ok(sig_bytes) = hex::decode(hex_sig) else {
return false;
};
mac.verify_slice(&sig_bytes).is_ok()
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,
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)?;
// 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(),
}),
));
}
};
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("");
// 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(),
}),
));
}
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}"
)))
// 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(),
}),
));
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;
}
// 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}"),
}
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}"),
}),
))
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,
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() {
// 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 {
} 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 {
};
match repo::webhook_configs::get_by_project_and_forge(
&state.pool,
project_id,
alt,
)
.await
.map_err(ApiError)?
{
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(),
}),
));
}
}
}
};
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("");
// 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(),
}),
));
}
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 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 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;
}
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}"),
}
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}"),
}),
))
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),
)
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

@ -1,19 +1,17 @@
use std::sync::Arc;
use std::time::Instant;
use std::{sync::Arc, time::Instant};
use dashmap::DashMap;
use fc_common::config::Config;
use fc_common::models::ApiKey;
use fc_common::{config::Config, models::ApiKey};
use sqlx::PgPool;
pub struct SessionData {
pub api_key: ApiKey,
pub created_at: Instant,
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>>,
pub pool: PgPool,
pub config: Config,
pub sessions: Arc<DashMap<String, SessionData>>,
}

File diff suppressed because it is too large Load diff

View file

@ -4,333 +4,331 @@
//!
//! Nix-dependent steps are skipped if nix is not available.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use fc_common::models::*;
use tower::ServiceExt;
async fn get_pool() -> Option<sqlx::PgPool> {
let url = match std::env::var("TEST_DATABASE_URL") {
Ok(url) => url,
Err(_) => {
println!("Skipping E2E test: TEST_DATABASE_URL not set");
return None;
}
};
let url = match std::env::var("TEST_DATABASE_URL") {
Ok(url) => url,
Err(_) => {
println!("Skipping E2E test: TEST_DATABASE_URL not set");
return None;
},
};
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.ok()?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.ok()?;
sqlx::migrate!("../common/migrations")
.run(&pool)
.await
.ok()?;
sqlx::migrate!("../common/migrations")
.run(&pool)
.await
.ok()?;
Some(pool)
Some(pool)
}
#[tokio::test]
async fn test_e2e_project_eval_build_flow() {
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
let pool = match get_pool().await {
Some(p) => p,
None => return,
};
// 1. Create a project
let project_name = format!("e2e-test-{}", uuid::Uuid::new_v4());
let project = fc_common::repo::projects::create(
&pool,
CreateProject {
name: project_name.clone(),
description: Some("E2E test project".to_string()),
repository_url: "https://github.com/test/e2e".to_string(),
},
// 1. Create a project
let project_name = format!("e2e-test-{}", uuid::Uuid::new_v4());
let project = fc_common::repo::projects::create(&pool, CreateProject {
name: project_name.clone(),
description: Some("E2E test project".to_string()),
repository_url: "https://github.com/test/e2e".to_string(),
})
.await
.expect("create project");
assert_eq!(project.name, project_name);
// 2. Create a jobset
let jobset = fc_common::repo::jobsets::create(&pool, CreateJobset {
project_id: project.id,
name: "default".to_string(),
nix_expression: "packages".to_string(),
enabled: Some(true),
flake_mode: Some(true),
check_interval: Some(300),
branch: None,
scheduling_shares: None,
})
.await
.expect("create jobset");
assert_eq!(jobset.project_id, project.id);
assert!(jobset.enabled);
// 3. Verify active jobsets include our new one
let active = fc_common::repo::jobsets::list_active(&pool)
.await
.expect("list active");
assert!(
active.iter().any(|j| j.id == jobset.id),
"new jobset should be in active list"
);
// 4. Create an evaluation
let eval = fc_common::repo::evaluations::create(&pool, CreateEvaluation {
jobset_id: jobset.id,
commit_hash: "e2e0000000000000000000000000000000000000".to_string(),
})
.await
.expect("create evaluation");
assert_eq!(eval.jobset_id, jobset.id);
assert_eq!(eval.status, EvaluationStatus::Pending);
// 5. Mark evaluation as running
fc_common::repo::evaluations::update_status(
&pool,
eval.id,
EvaluationStatus::Running,
None,
)
.await
.expect("update eval status");
// 6. Create builds as if nix evaluation found jobs
let build1 = fc_common::repo::builds::create(&pool, CreateBuild {
evaluation_id: eval.id,
job_name: "hello".to_string(),
drv_path: "/nix/store/e2e000-hello.drv".to_string(),
system: Some("x86_64-linux".to_string()),
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-hello"})),
is_aggregate: Some(false),
constituents: None,
})
.await
.expect("create build 1");
let build2 = fc_common::repo::builds::create(&pool, CreateBuild {
evaluation_id: eval.id,
job_name: "world".to_string(),
drv_path: "/nix/store/e2e000-world.drv".to_string(),
system: Some("x86_64-linux".to_string()),
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-world"})),
is_aggregate: Some(false),
constituents: None,
})
.await
.expect("create build 2");
assert_eq!(build1.status, BuildStatus::Pending);
assert_eq!(build2.status, BuildStatus::Pending);
// 7. Create build dependency (hello depends on world)
fc_common::repo::build_dependencies::create(&pool, build1.id, build2.id)
.await
.expect("create dependency");
// 8. Verify dependency check: build1 deps NOT complete (world is still
// pending)
let deps_complete =
fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
.await
.expect("check deps");
assert!(!deps_complete, "deps should NOT be complete yet");
// 9. Complete build2 (world)
fc_common::repo::builds::start(&pool, build2.id)
.await
.expect("start build2");
fc_common::repo::builds::complete(
&pool,
build2.id,
BuildStatus::Completed,
None,
Some("/nix/store/e2e000-world"),
None,
)
.await
.expect("complete build2");
// 10. Now build1 deps should be complete
let deps_complete =
fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
.await
.expect("check deps again");
assert!(deps_complete, "deps should be complete after build2 done");
// 11. Complete build1 (hello)
fc_common::repo::builds::start(&pool, build1.id)
.await
.expect("start build1");
let step = fc_common::repo::build_steps::create(&pool, CreateBuildStep {
build_id: build1.id,
step_number: 1,
command: "nix build /nix/store/e2e000-hello.drv".to_string(),
})
.await
.expect("create step");
fc_common::repo::build_steps::complete(
&pool,
step.id,
0,
Some("built!"),
None,
)
.await
.expect("complete step");
fc_common::repo::build_products::create(&pool, CreateBuildProduct {
build_id: build1.id,
name: "out".to_string(),
path: "/nix/store/e2e000-hello".to_string(),
sha256_hash: Some("abcdef1234567890".to_string()),
file_size: Some(12345),
content_type: None,
is_directory: true,
})
.await
.expect("create product");
fc_common::repo::builds::complete(
&pool,
build1.id,
BuildStatus::Completed,
None,
Some("/nix/store/e2e000-hello"),
None,
)
.await
.expect("complete build1");
// 12. Mark evaluation as completed
fc_common::repo::evaluations::update_status(
&pool,
eval.id,
EvaluationStatus::Completed,
None,
)
.await
.expect("complete eval");
// 13. Verify everything is in the expected state
let final_eval = fc_common::repo::evaluations::get(&pool, eval.id)
.await
.expect("get eval");
assert_eq!(final_eval.status, EvaluationStatus::Completed);
let final_build1 = fc_common::repo::builds::get(&pool, build1.id)
.await
.expect("get build1");
assert_eq!(final_build1.status, BuildStatus::Completed);
assert_eq!(
final_build1.build_output_path.as_deref(),
Some("/nix/store/e2e000-hello")
);
let products =
fc_common::repo::build_products::list_for_build(&pool, build1.id)
.await
.expect("list products");
assert_eq!(products.len(), 1);
assert_eq!(products[0].name, "out");
let steps = fc_common::repo::build_steps::list_for_build(&pool, build1.id)
.await
.expect("list steps");
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].exit_code, Some(0));
// 14. Verify build stats reflect our changes
let stats = fc_common::repo::builds::get_stats(&pool)
.await
.expect("get stats");
assert!(stats.completed_builds.unwrap_or(0) >= 2);
// 15. Create a channel and verify it works
let channel = fc_common::repo::channels::create(&pool, CreateChannel {
project_id: project.id,
name: "stable".to_string(),
jobset_id: jobset.id,
})
.await
.expect("create channel");
let channels = fc_common::repo::channels::list_all(&pool)
.await
.expect("list channels");
assert!(channels.iter().any(|c| c.id == channel.id));
// 16. Test the HTTP API layer
let config = fc_common::config::Config::default();
let server_config = config.server.clone();
let state = fc_server::state::AppState {
pool: pool.clone(),
config,
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
};
let app = fc_server::routes::router(state, &server_config);
// GET /health
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.expect("create project");
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(project.name, project_name);
// 2. Create a jobset
let jobset = fc_common::repo::jobsets::create(
&pool,
CreateJobset {
project_id: project.id,
name: "default".to_string(),
nix_expression: "packages".to_string(),
enabled: Some(true),
flake_mode: Some(true),
check_interval: Some(300),
branch: None,
scheduling_shares: None,
},
// GET /api/v1/projects/{id}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/projects/{}", project.id))
.body(Body::empty())
.unwrap(),
)
.await
.expect("create jobset");
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(jobset.project_id, project.id);
assert!(jobset.enabled);
// 3. Verify active jobsets include our new one
let active = fc_common::repo::jobsets::list_active(&pool)
.await
.expect("list active");
assert!(
active.iter().any(|j| j.id == jobset.id),
"new jobset should be in active list"
);
// 4. Create an evaluation
let eval = fc_common::repo::evaluations::create(
&pool,
CreateEvaluation {
jobset_id: jobset.id,
commit_hash: "e2e0000000000000000000000000000000000000".to_string(),
},
// GET /api/v1/builds/{id}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/builds/{}", build1.id))
.body(Body::empty())
.unwrap(),
)
.await
.expect("create evaluation");
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(eval.jobset_id, jobset.id);
assert_eq!(eval.status, EvaluationStatus::Pending);
// 5. Mark evaluation as running
fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Running, None)
.await
.expect("update eval status");
// 6. Create builds as if nix evaluation found jobs
let build1 = fc_common::repo::builds::create(
&pool,
CreateBuild {
evaluation_id: eval.id,
job_name: "hello".to_string(),
drv_path: "/nix/store/e2e000-hello.drv".to_string(),
system: Some("x86_64-linux".to_string()),
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-hello"})),
is_aggregate: Some(false),
constituents: None,
},
)
// GET / (dashboard)
let resp = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("create build 1");
let build2 = fc_common::repo::builds::create(
&pool,
CreateBuild {
evaluation_id: eval.id,
job_name: "world".to_string(),
drv_path: "/nix/store/e2e000-world.drv".to_string(),
system: Some("x86_64-linux".to_string()),
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-world"})),
is_aggregate: Some(false),
constituents: None,
},
)
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("create build 2");
.unwrap();
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Dashboard"));
assert_eq!(build1.status, BuildStatus::Pending);
assert_eq!(build2.status, BuildStatus::Pending);
// 7. Create build dependency (hello depends on world)
fc_common::repo::build_dependencies::create(&pool, build1.id, build2.id)
.await
.expect("create dependency");
// 8. Verify dependency check: build1 deps NOT complete (world is still pending)
let deps_complete = fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
.await
.expect("check deps");
assert!(!deps_complete, "deps should NOT be complete yet");
// 9. Complete build2 (world)
fc_common::repo::builds::start(&pool, build2.id)
.await
.expect("start build2");
fc_common::repo::builds::complete(
&pool,
build2.id,
BuildStatus::Completed,
None,
Some("/nix/store/e2e000-world"),
None,
)
.await
.expect("complete build2");
// 10. Now build1 deps should be complete
let deps_complete = fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
.await
.expect("check deps again");
assert!(deps_complete, "deps should be complete after build2 done");
// 11. Complete build1 (hello)
fc_common::repo::builds::start(&pool, build1.id)
.await
.expect("start build1");
let step = fc_common::repo::build_steps::create(
&pool,
CreateBuildStep {
build_id: build1.id,
step_number: 1,
command: "nix build /nix/store/e2e000-hello.drv".to_string(),
},
)
.await
.expect("create step");
fc_common::repo::build_steps::complete(&pool, step.id, 0, Some("built!"), None)
.await
.expect("complete step");
fc_common::repo::build_products::create(
&pool,
CreateBuildProduct {
build_id: build1.id,
name: "out".to_string(),
path: "/nix/store/e2e000-hello".to_string(),
sha256_hash: Some("abcdef1234567890".to_string()),
file_size: Some(12345),
content_type: None,
is_directory: true,
},
)
.await
.expect("create product");
fc_common::repo::builds::complete(
&pool,
build1.id,
BuildStatus::Completed,
None,
Some("/nix/store/e2e000-hello"),
None,
)
.await
.expect("complete build1");
// 12. Mark evaluation as completed
fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Completed, None)
.await
.expect("complete eval");
// 13. Verify everything is in the expected state
let final_eval = fc_common::repo::evaluations::get(&pool, eval.id)
.await
.expect("get eval");
assert_eq!(final_eval.status, EvaluationStatus::Completed);
let final_build1 = fc_common::repo::builds::get(&pool, build1.id)
.await
.expect("get build1");
assert_eq!(final_build1.status, BuildStatus::Completed);
assert_eq!(
final_build1.build_output_path.as_deref(),
Some("/nix/store/e2e000-hello")
);
let products = fc_common::repo::build_products::list_for_build(&pool, build1.id)
.await
.expect("list products");
assert_eq!(products.len(), 1);
assert_eq!(products[0].name, "out");
let steps = fc_common::repo::build_steps::list_for_build(&pool, build1.id)
.await
.expect("list steps");
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].exit_code, Some(0));
// 14. Verify build stats reflect our changes
let stats = fc_common::repo::builds::get_stats(&pool)
.await
.expect("get stats");
assert!(stats.completed_builds.unwrap_or(0) >= 2);
// 15. Create a channel and verify it works
let channel = fc_common::repo::channels::create(
&pool,
CreateChannel {
project_id: project.id,
name: "stable".to_string(),
jobset_id: jobset.id,
},
)
.await
.expect("create channel");
let channels = fc_common::repo::channels::list_all(&pool)
.await
.expect("list channels");
assert!(channels.iter().any(|c| c.id == channel.id));
// 16. Test the HTTP API layer
let config = fc_common::config::Config::default();
let server_config = config.server.clone();
let state = fc_server::state::AppState {
pool: pool.clone(),
config,
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
};
let app = fc_server::routes::router(state, &server_config);
// GET /health
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// GET /api/v1/projects/{id}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/projects/{}", project.id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// GET /api/v1/builds/{id}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/api/v1/builds/{}", build1.id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// GET / (dashboard)
let resp = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Dashboard"));
// Clean up
let _ = fc_common::repo::projects::delete(&pool, project.id).await;
// Clean up
let _ = fc_common::repo::projects::delete(&pool, project.id).await;
}