crates/server: REST API routes; RBAC auth middleware; cookie sessions; dashboard
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I5298a925bd9c11780e49d8b1c98eebd86a6a6964
This commit is contained in:
parent
44d1ee1d6b
commit
235d3d38a6
38 changed files with 6275 additions and 7 deletions
|
|
@ -20,4 +20,21 @@ anyhow.workspace = true
|
|||
thiserror.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
fc-common = { path = "../common" }
|
||||
tower-http.workspace = true
|
||||
tower.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
hmac.workspace = true
|
||||
tokio-util.workspace = true
|
||||
async-stream.workspace = true
|
||||
futures.workspace = true
|
||||
axum-extra.workspace = true
|
||||
dashmap.workspace = true
|
||||
askama.workspace = true
|
||||
askama_axum.workspace = true
|
||||
|
||||
# Our crates
|
||||
fc-common.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tower.workspace = true
|
||||
|
|
|
|||
162
crates/server/src/auth_middleware.rs
Normal file
162
crates/server/src/auth_middleware.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
use axum::{
|
||||
extract::{FromRequestParts, Request, State},
|
||||
http::{StatusCode, request::Parts},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use fc_common::models::ApiKey;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extract and validate an API key from the Authorization header.
|
||||
/// Keys use the format: `Bearer fc_xxxx`.
|
||||
/// Write endpoints (POST/PUT/DELETE/PATCH) require a valid key.
|
||||
/// Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for dashboard admin UI).
|
||||
pub async fn require_api_key(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let method = request.method().clone();
|
||||
let is_read = method == axum::http::Method::GET
|
||||
|| method == axum::http::Method::HEAD
|
||||
|| method == axum::http::Method::OPTIONS;
|
||||
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
let token = auth_header
|
||||
.as_deref()
|
||||
.and_then(|h| h.strip_prefix("Bearer "));
|
||||
|
||||
match token {
|
||||
Some(token) => {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
|
||||
match fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await {
|
||||
Ok(Some(api_key)) => {
|
||||
// Touch last_used_at (fire and forget)
|
||||
let pool = state.pool.clone();
|
||||
let key_id = api_key.id;
|
||||
tokio::spawn(async move {
|
||||
let _ = fc_common::repo::api_keys::touch_last_used(&pool, key_id).await;
|
||||
});
|
||||
|
||||
request.extensions_mut().insert(api_key);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
_ => {
|
||||
if is_read {
|
||||
// Invalid token on read is still allowed, just no ApiKey in extensions
|
||||
Ok(next.run(request).await)
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if is_read {
|
||||
Ok(next.run(request).await)
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extractor that requires an authenticated admin user.
|
||||
/// Use as a handler parameter: `_auth: RequireAdmin`
|
||||
pub struct RequireAdmin(pub ApiKey);
|
||||
|
||||
impl FromRequestParts<AppState> for RequireAdmin {
|
||||
type Rejection = StatusCode;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
_state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let key = parts
|
||||
.extensions
|
||||
.get::<ApiKey>()
|
||||
.cloned()
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
if key.role == "admin" {
|
||||
Ok(RequireAdmin(key))
|
||||
} else {
|
||||
Err(StatusCode::FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extractor that requires one of the specified roles (admin always passes).
|
||||
/// Use as: `_auth: RequireRole<"cancel-build", "restart-jobs">`
|
||||
///
|
||||
/// Since const generics with strings aren't stable, use the helper function instead.
|
||||
pub struct RequireRoles(pub ApiKey);
|
||||
|
||||
impl RequireRoles {
|
||||
pub fn check(
|
||||
extensions: &axum::http::Extensions,
|
||||
allowed: &[&str],
|
||||
) -> Result<ApiKey, StatusCode> {
|
||||
let key = extensions
|
||||
.get::<ApiKey>()
|
||||
.cloned()
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
if key.role == "admin" || allowed.contains(&key.role.as_str()) {
|
||||
Ok(key)
|
||||
} else {
|
||||
Err(StatusCode::FORBIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session extraction middleware for dashboard routes.
|
||||
/// Reads `fc_session` cookie and inserts ApiKey into extensions if valid.
|
||||
pub async fn extract_session(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if let Some(cookie_header) = request
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if let Some(session_id) = parse_cookie(cookie_header, "fc_session") {
|
||||
if let Some(session) = state.sessions.get(&session_id) {
|
||||
// Check session expiry (24 hours)
|
||||
if session.created_at.elapsed() < std::time::Duration::from_secs(24 * 60 * 60) {
|
||||
request.extensions_mut().insert(session.api_key.clone());
|
||||
} else {
|
||||
// Expired, remove it
|
||||
drop(session);
|
||||
state.sessions.remove(&session_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn parse_cookie<'a>(header: &'a str, name: &str) -> Option<String> {
|
||||
header
|
||||
.split(';')
|
||||
.filter_map(|pair| {
|
||||
let pair = pair.trim();
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
if k.trim() == name {
|
||||
Some(v.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
}
|
||||
40
crates/server/src/error.rs
Normal file
40
crates/server/src/error.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fc_common::CiError;
|
||||
use serde_json::json;
|
||||
|
||||
pub struct ApiError(pub CiError);
|
||||
|
||||
impl From<CiError> for ApiError {
|
||||
fn from(err: CiError) -> Self {
|
||||
ApiError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code, message) = match &self.0 {
|
||||
CiError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
|
||||
CiError::Validation(msg) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR", msg.clone()),
|
||||
CiError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()),
|
||||
CiError::Timeout(msg) => (StatusCode::REQUEST_TIMEOUT, "TIMEOUT", msg.clone()),
|
||||
CiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.clone()),
|
||||
CiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()),
|
||||
CiError::Database(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"DATABASE_ERROR",
|
||||
"Internal database error".to_string(),
|
||||
),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_ERROR",
|
||||
"Internal server error".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = axum::Json(json!({ "error": message, "error_code": code }));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
4
crates/server/src/lib.rs
Normal file
4
crates/server/src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod auth_middleware;
|
||||
pub mod error;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
|
|
@ -1,22 +1,79 @@
|
|||
use fc_server::routes;
|
||||
use fc_server::state;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::fmt::init;
|
||||
use fc_common::{Config, Database};
|
||||
use state::AppState;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "fc-server")]
|
||||
#[command(about = "CI Server - Web API and UI")]
|
||||
struct Cli {
|
||||
#[arg(short, long, default_value = "3000")]
|
||||
port: u16,
|
||||
#[arg(short = 'H', long)]
|
||||
host: Option<String>,
|
||||
|
||||
#[arg(short, long)]
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
() = ctrl_c => {},
|
||||
() = terminate => {},
|
||||
}
|
||||
|
||||
tracing::info!("Shutdown signal received");
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let config = Config::load()?;
|
||||
|
||||
tracing::info!("Starting CI Server on port {}", cli.port);
|
||||
init();
|
||||
let host = cli.host.unwrap_or(config.server.host.clone());
|
||||
let port = cli.port.unwrap_or(config.server.port);
|
||||
|
||||
// TODO: Implement server logic
|
||||
let db = Database::new(config.database.clone()).await?;
|
||||
|
||||
let state = AppState {
|
||||
pool: db.pool().clone(),
|
||||
config: config.clone(),
|
||||
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
|
||||
};
|
||||
|
||||
let app = routes::router(state, &config.server);
|
||||
|
||||
let bind_addr = format!("{host}:{port}");
|
||||
tracing::info!("Starting CI Server on {}", bind_addr);
|
||||
|
||||
let listener = TcpListener::bind(&bind_addr).await?;
|
||||
let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
tracing::info!("Server shutting down, closing database pool");
|
||||
db.close().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
125
crates/server/src/routes/admin.rs
Normal file
125
crates/server/src/routes/admin.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
routing::get,
|
||||
};
|
||||
use fc_common::Validate;
|
||||
use fc_common::models::{CreateRemoteBuilder, RemoteBuilder, SystemStatus, UpdateRemoteBuilder};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireAdmin;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn list_builders(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<RemoteBuilder>>, ApiError> {
|
||||
let builders = fc_common::repo::remote_builders::list(&state.pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(builders))
|
||||
}
|
||||
|
||||
async fn get_builder(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<RemoteBuilder>, ApiError> {
|
||||
let builder = fc_common::repo::remote_builders::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(builder))
|
||||
}
|
||||
|
||||
async fn create_builder(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateRemoteBuilder>,
|
||||
) -> Result<Json<RemoteBuilder>, ApiError> {
|
||||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let builder = fc_common::repo::remote_builders::create(&state.pool, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(builder))
|
||||
}
|
||||
|
||||
async fn update_builder(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<UpdateRemoteBuilder>,
|
||||
) -> Result<Json<RemoteBuilder>, ApiError> {
|
||||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let builder = fc_common::repo::remote_builders::update(&state.pool, id, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(builder))
|
||||
}
|
||||
|
||||
async fn delete_builder(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
fc_common::repo::remote_builders::delete(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(serde_json::json!({"deleted": true})))
|
||||
}
|
||||
|
||||
async fn system_status(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<SystemStatus>, ApiError> {
|
||||
let pool = &state.pool;
|
||||
|
||||
let projects: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
let jobsets: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobsets")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
let evaluations: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
let stats = fc_common::repo::builds::get_stats(pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let builders = fc_common::repo::remote_builders::count(pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let channels: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM channels")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
Ok(Json(SystemStatus {
|
||||
projects_count: projects.0,
|
||||
jobsets_count: jobsets.0,
|
||||
evaluations_count: evaluations.0,
|
||||
builds_pending: stats.pending_builds.unwrap_or(0),
|
||||
builds_running: stats.running_builds.unwrap_or(0),
|
||||
builds_completed: stats.completed_builds.unwrap_or(0),
|
||||
builds_failed: stats.failed_builds.unwrap_or(0),
|
||||
remote_builders: builders,
|
||||
channels_count: channels.0,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/builders", get(list_builders).post(create_builder))
|
||||
.route(
|
||||
"/admin/builders/{id}",
|
||||
get(get_builder).put(update_builder).delete(delete_builder),
|
||||
)
|
||||
.route("/admin/system", get(system_status))
|
||||
}
|
||||
98
crates/server/src/routes/auth.rs
Normal file
98
crates/server/src/routes/auth.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use fc_common::repo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireAdmin;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateApiKeyRequest {
|
||||
pub name: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateApiKeyResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiKeyInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
pub fn hash_api_key(key: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
async fn create_api_key(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateApiKeyRequest>,
|
||||
) -> Result<Json<CreateApiKeyResponse>, ApiError> {
|
||||
let role = input.role.unwrap_or_else(|| "read-only".to_string());
|
||||
|
||||
// Generate a random API key
|
||||
let key = format!("fc_{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let key_hash = hash_api_key(&key);
|
||||
|
||||
let api_key = repo::api_keys::create(&state.pool, &input.name, &key_hash, &role)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
Ok(Json(CreateApiKeyResponse {
|
||||
id: api_key.id,
|
||||
name: api_key.name,
|
||||
key, // Only returned once at creation time
|
||||
role: api_key.role,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_api_keys(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<ApiKeyInfo>>, ApiError> {
|
||||
let keys = repo::api_keys::list(&state.pool).await.map_err(ApiError)?;
|
||||
|
||||
let infos: Vec<ApiKeyInfo> = keys
|
||||
.into_iter()
|
||||
.map(|k| ApiKeyInfo {
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
role: k.role,
|
||||
created_at: k.created_at,
|
||||
last_used_at: k.last_used_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(infos))
|
||||
}
|
||||
|
||||
async fn delete_api_key(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
repo::api_keys::delete(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api-keys", get(list_api_keys).post(create_api_key))
|
||||
.route("/api-keys/{id}", axum::routing::delete(delete_api_key))
|
||||
}
|
||||
171
crates/server/src/routes/badges.rs
Normal file
171
crates/server/src/routes/badges.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
use axum::{
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn build_badge(
|
||||
State(state): State<AppState>,
|
||||
Path((project_name, jobset_name, job_name)): Path<(String, String, String)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
// Find the project
|
||||
let project = fc_common::repo::projects::get_by_name(&state.pool, &project_name)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
// Find the jobset
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(&state.pool, project.id, 1000, 0)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let jobset = jobsets.iter().find(|j| j.name == jobset_name);
|
||||
let jobset = match jobset {
|
||||
Some(j) => j,
|
||||
None => {
|
||||
return Ok(shield_svg("build", "not found", "#9f9f9f").into_response());
|
||||
}
|
||||
};
|
||||
|
||||
// Get latest evaluation
|
||||
let eval = fc_common::repo::evaluations::get_latest(&state.pool, jobset.id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let eval = match eval {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return Ok(shield_svg("build", "no evaluations", "#9f9f9f").into_response());
|
||||
}
|
||||
};
|
||||
|
||||
// Find the build for this job
|
||||
let builds = fc_common::repo::builds::list_for_evaluation(&state.pool, eval.id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let build = builds.iter().find(|b| b.job_name == job_name);
|
||||
|
||||
let (label, color) = match build {
|
||||
Some(b) => match b.status {
|
||||
fc_common::BuildStatus::Completed => ("passing", "#4c1"),
|
||||
fc_common::BuildStatus::Failed => ("failing", "#e05d44"),
|
||||
fc_common::BuildStatus::Running => ("building", "#dfb317"),
|
||||
fc_common::BuildStatus::Pending => ("queued", "#dfb317"),
|
||||
fc_common::BuildStatus::Cancelled => ("cancelled", "#9f9f9f"),
|
||||
},
|
||||
None => ("not found", "#9f9f9f"),
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", "image/svg+xml"),
|
||||
("cache-control", "no-cache, no-store, must-revalidate"),
|
||||
],
|
||||
shield_svg("build", label, color),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Latest successful build redirect
|
||||
async fn latest_build(
|
||||
State(state): State<AppState>,
|
||||
Path((project_name, jobset_name, job_name)): Path<(String, String, String)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let project = fc_common::repo::projects::get_by_name(&state.pool, &project_name)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(&state.pool, project.id, 1000, 0)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let jobset = jobsets.iter().find(|j| j.name == jobset_name);
|
||||
let jobset = match jobset {
|
||||
Some(j) => j,
|
||||
None => {
|
||||
return Ok((StatusCode::NOT_FOUND, "Jobset not found").into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let eval = fc_common::repo::evaluations::get_latest(&state.pool, jobset.id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let eval = match eval {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return Ok((StatusCode::NOT_FOUND, "No evaluations found").into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let builds = fc_common::repo::builds::list_for_evaluation(&state.pool, eval.id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let build = builds.iter().find(|b| b.job_name == job_name);
|
||||
match build {
|
||||
Some(b) => Ok(axum::Json(b.clone()).into_response()),
|
||||
None => Ok((StatusCode::NOT_FOUND, "Build not found").into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
fn shield_svg(subject: &str, status: &str, color: &str) -> String {
|
||||
let subject_width = subject.len() * 7 + 10;
|
||||
let status_width = status.len() * 7 + 10;
|
||||
let total_width = subject_width + status_width;
|
||||
let subject_x = subject_width / 2;
|
||||
let status_x = subject_width + status_width / 2;
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_width}\" height=\"20\">\n"
|
||||
));
|
||||
svg.push_str(" <linearGradient id=\"b\" x2=\"0\" y2=\"100%\">\n");
|
||||
svg.push_str(" <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n");
|
||||
svg.push_str(" <stop offset=\"1\" stop-opacity=\".1\"/>\n");
|
||||
svg.push_str(" </linearGradient>\n");
|
||||
svg.push_str(" <mask id=\"a\">\n");
|
||||
svg.push_str(&format!(
|
||||
" <rect width=\"{total_width}\" height=\"20\" rx=\"3\" fill=\"#fff\"/>\n"
|
||||
));
|
||||
svg.push_str(" </mask>\n");
|
||||
svg.push_str(" <g mask=\"url(#a)\">\n");
|
||||
svg.push_str(&format!(
|
||||
" <rect width=\"{subject_width}\" height=\"20\" fill=\"#555\"/>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <rect x=\"{subject_width}\" width=\"{status_width}\" height=\"20\" fill=\"{color}\"/>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <rect width=\"{total_width}\" height=\"20\" fill=\"url(#b)\"/>\n"
|
||||
));
|
||||
svg.push_str(" </g>\n");
|
||||
svg.push_str(" <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n");
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{subject_x}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{subject}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{subject_x}\" y=\"14\">{subject}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{status_x}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{status}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{status_x}\" y=\"14\">{status}</text>\n"
|
||||
));
|
||||
svg.push_str(" </g>\n");
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/job/{project}/{jobset}/{job}/shield", get(build_badge))
|
||||
.route("/job/{project}/{jobset}/{job}/latest", get(latest_build))
|
||||
}
|
||||
334
crates/server/src/routes/builds.rs
Normal file
334
crates/server/src/routes/builds.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{Extensions, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use fc_common::{
|
||||
Build, BuildProduct, BuildStatus, BuildStep, CreateBuild, PaginatedResponse, PaginationParams,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireRoles;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
fn check_role(extensions: &Extensions, allowed: &[&str]) -> Result<(), ApiError> {
|
||||
RequireRoles::check(extensions, allowed)
|
||||
.map(|_| ())
|
||||
.map_err(|s| {
|
||||
ApiError(if s == StatusCode::FORBIDDEN {
|
||||
fc_common::CiError::Forbidden("Insufficient permissions".to_string())
|
||||
} else {
|
||||
fc_common::CiError::Unauthorized("Authentication required".to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListBuildsParams {
|
||||
evaluation_id: Option<Uuid>,
|
||||
status: Option<String>,
|
||||
system: Option<String>,
|
||||
job_name: Option<String>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list_builds(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ListBuildsParams>,
|
||||
) -> Result<Json<PaginatedResponse<Build>>, ApiError> {
|
||||
let pagination = PaginationParams {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
};
|
||||
let limit = pagination.limit();
|
||||
let offset = pagination.offset();
|
||||
let items = fc_common::repo::builds::list_filtered(
|
||||
&state.pool,
|
||||
params.evaluation_id,
|
||||
params.status.as_deref(),
|
||||
params.system.as_deref(),
|
||||
params.job_name.as_deref(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let total = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
params.evaluation_id,
|
||||
params.status.as_deref(),
|
||||
params.system.as_deref(),
|
||||
params.job_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(PaginatedResponse {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_build(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Build>, ApiError> {
|
||||
let build = fc_common::repo::builds::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(build))
|
||||
}
|
||||
|
||||
async fn cancel_build(
|
||||
extensions: Extensions,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Build>>, ApiError> {
|
||||
check_role(&extensions, &["cancel-build"])?;
|
||||
let cancelled = fc_common::repo::builds::cancel_cascade(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
if cancelled.is_empty() {
|
||||
return Err(ApiError(fc_common::CiError::NotFound(
|
||||
"Build not found or not in a cancellable state".to_string(),
|
||||
)));
|
||||
}
|
||||
Ok(Json(cancelled))
|
||||
}
|
||||
|
||||
async fn list_build_steps(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<BuildStep>>, ApiError> {
|
||||
let steps = fc_common::repo::build_steps::list_for_build(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(steps))
|
||||
}
|
||||
|
||||
async fn list_build_products(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<BuildProduct>>, ApiError> {
|
||||
let products = fc_common::repo::build_products::list_for_build(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(products))
|
||||
}
|
||||
|
||||
async fn build_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<fc_common::BuildStats>, ApiError> {
|
||||
let stats = fc_common::repo::builds::get_stats(&state.pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
async fn recent_builds(State(state): State<AppState>) -> Result<Json<Vec<Build>>, ApiError> {
|
||||
let builds = fc_common::repo::builds::list_recent(&state.pool, 20)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(builds))
|
||||
}
|
||||
|
||||
async fn list_project_builds(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Build>>, ApiError> {
|
||||
let builds = fc_common::repo::builds::list_for_project(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(builds))
|
||||
}
|
||||
|
||||
async fn restart_build(
|
||||
extensions: Extensions,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Build>, ApiError> {
|
||||
check_role(&extensions, &["restart-jobs"])?;
|
||||
let original = fc_common::repo::builds::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
// Can only restart completed or failed builds
|
||||
if original.status != BuildStatus::Failed
|
||||
&& original.status != BuildStatus::Completed
|
||||
&& original.status != BuildStatus::Cancelled
|
||||
{
|
||||
return Err(ApiError(fc_common::CiError::Validation(
|
||||
"Can only restart failed, completed, or cancelled builds".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// Create a new build with the same parameters
|
||||
let new_build = fc_common::repo::builds::create(
|
||||
&state.pool,
|
||||
CreateBuild {
|
||||
evaluation_id: original.evaluation_id,
|
||||
job_name: original.job_name.clone(),
|
||||
drv_path: original.drv_path.clone(),
|
||||
system: original.system.clone(),
|
||||
outputs: original.outputs.clone(),
|
||||
is_aggregate: Some(original.is_aggregate),
|
||||
constituents: original.constituents.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
tracing::info!(
|
||||
original_id = %id,
|
||||
new_id = %new_build.id,
|
||||
job = %original.job_name,
|
||||
"Build restarted"
|
||||
);
|
||||
|
||||
Ok(Json(new_build))
|
||||
}
|
||||
|
||||
async fn bump_build(
|
||||
extensions: Extensions,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Build>, ApiError> {
|
||||
check_role(&extensions, &["bump-to-front"])?;
|
||||
let build = sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET priority = priority + 10 WHERE id = $1 AND status = 'pending' RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?
|
||||
.ok_or_else(|| {
|
||||
ApiError(fc_common::CiError::Validation(
|
||||
"Build not found or not in pending state".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Json(build))
|
||||
}
|
||||
|
||||
async fn download_build_product(
|
||||
State(state): State<AppState>,
|
||||
Path((build_id, product_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
// Verify build exists
|
||||
let _build = fc_common::repo::builds::get(&state.pool, build_id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let product = fc_common::repo::build_products::get(&state.pool, product_id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
if product.build_id != build_id {
|
||||
return Err(ApiError(fc_common::CiError::NotFound(
|
||||
"Product does not belong to this build".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
if !fc_common::validate::is_valid_store_path(&product.path) {
|
||||
return Err(ApiError(fc_common::CiError::Validation(
|
||||
"Invalid store path".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
if product.is_directory {
|
||||
// Stream as NAR using nix store dump-path
|
||||
let child = tokio::process::Command::new("nix")
|
||||
.args(["store", "dump-path", &product.path])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Err(ApiError(fc_common::CiError::Build(format!(
|
||||
"Failed to dump path: {e}"
|
||||
))));
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = match child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(ApiError(fc_common::CiError::Build(
|
||||
"Failed to capture output".to_string(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let stream = tokio_util::io::ReaderStream::new(stdout);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
let filename = product.path.rsplit('/').next().unwrap_or(&product.name);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", "application/x-nix-nar"),
|
||||
(
|
||||
"content-disposition",
|
||||
&format!("attachment; filename=\"{filename}.nar\""),
|
||||
),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
// Serve file directly
|
||||
let file = tokio::fs::File::open(&product.path)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Io(e)))?;
|
||||
|
||||
let stream = tokio_util::io::ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
let content_type = product
|
||||
.content_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
let filename = product.path.rsplit('/').next().unwrap_or(&product.name);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", content_type),
|
||||
(
|
||||
"content-disposition",
|
||||
&format!("attachment; filename=\"{filename}\""),
|
||||
),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/builds", get(list_builds))
|
||||
.route("/builds/stats", get(build_stats))
|
||||
.route("/builds/recent", get(recent_builds))
|
||||
.route("/builds/{id}", get(get_build))
|
||||
.route("/builds/{id}/cancel", post(cancel_build))
|
||||
.route("/builds/{id}/restart", post(restart_build))
|
||||
.route("/builds/{id}/bump", post(bump_build))
|
||||
.route("/builds/{id}/steps", get(list_build_steps))
|
||||
.route("/builds/{id}/products", get(list_build_products))
|
||||
.route(
|
||||
"/builds/{build_id}/products/{product_id}/download",
|
||||
get(download_build_product),
|
||||
)
|
||||
.route("/projects/{id}/builds", get(list_project_builds))
|
||||
}
|
||||
367
crates/server/src/routes/cache.rs
Normal file
367
crates/server/src/routes/cache.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Serve NARInfo for a store path hash.
|
||||
/// GET /nix-cache/{hash}.narinfo
|
||||
async fn narinfo(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !state.config.cache.enabled {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
// Strip .narinfo suffix if present
|
||||
let hash = hash.strip_suffix(".narinfo").unwrap_or(&hash);
|
||||
|
||||
if !fc_common::validate::is_valid_nix_hash(hash) {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
// Look up the store path from build_products by matching the hash prefix
|
||||
let product = sqlx::query_as::<_, fc_common::models::BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE path LIKE $1 LIMIT 1",
|
||||
)
|
||||
.bind(format!("/nix/store/{hash}-%"))
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
let product = match product {
|
||||
Some(p) => p,
|
||||
None => return Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
};
|
||||
|
||||
if !fc_common::validate::is_valid_store_path(&product.path) {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
// Get narinfo from nix path-info
|
||||
let output = Command::new("nix")
|
||||
.args(["path-info", "--json", &product.path])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let output = match output {
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&stdout) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
};
|
||||
|
||||
let entry = match parsed.as_array().and_then(|a| a.first()) {
|
||||
Some(e) => e,
|
||||
None => return Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
};
|
||||
|
||||
let nar_hash = entry.get("narHash").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let nar_size = entry.get("narSize").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let store_path = entry
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&product.path);
|
||||
|
||||
let refs: Vec<&str> = entry
|
||||
.get("references")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|r| r.as_str())
|
||||
.map(|s| s.strip_prefix("/nix/store/").unwrap_or(s))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract deriver
|
||||
let deriver = entry
|
||||
.get("deriver")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|d| d.strip_prefix("/nix/store/").unwrap_or(d));
|
||||
|
||||
// Extract content-addressable hash
|
||||
let ca = entry.get("ca").and_then(|v| v.as_str());
|
||||
|
||||
let file_hash = nar_hash;
|
||||
|
||||
let mut narinfo_text = format!(
|
||||
"StorePath: {store_path}\n\
|
||||
URL: nar/{hash}.nar.zst\n\
|
||||
Compression: zstd\n\
|
||||
FileHash: {file_hash}\n\
|
||||
FileSize: {nar_size}\n\
|
||||
NarHash: {nar_hash}\n\
|
||||
NarSize: {nar_size}\n\
|
||||
References: {refs}\n",
|
||||
store_path = store_path,
|
||||
hash = hash,
|
||||
file_hash = file_hash,
|
||||
nar_size = nar_size,
|
||||
nar_hash = nar_hash,
|
||||
refs = refs.join(" "),
|
||||
);
|
||||
|
||||
if let Some(deriver) = deriver {
|
||||
narinfo_text.push_str(&format!("Deriver: {deriver}\n"));
|
||||
}
|
||||
if let Some(ca) = ca {
|
||||
narinfo_text.push_str(&format!("CA: {ca}\n"));
|
||||
}
|
||||
|
||||
// Optionally sign if secret key is configured
|
||||
let narinfo_text = if let Some(ref key_file) = state.config.cache.secret_key_file {
|
||||
if key_file.exists() {
|
||||
sign_narinfo(&narinfo_text, key_file).await
|
||||
} else {
|
||||
narinfo_text
|
||||
}
|
||||
} else {
|
||||
narinfo_text
|
||||
};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/x-nix-narinfo")],
|
||||
narinfo_text,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Sign narinfo using nix store sign command
|
||||
async fn sign_narinfo(narinfo: &str, key_file: &std::path::Path) -> String {
|
||||
let store_path = narinfo
|
||||
.lines()
|
||||
.find(|l| l.starts_with("StorePath: "))
|
||||
.and_then(|l| l.strip_prefix("StorePath: "));
|
||||
|
||||
let store_path = match store_path {
|
||||
Some(p) => p,
|
||||
None => return narinfo.to_string(),
|
||||
};
|
||||
|
||||
let output = Command::new("nix")
|
||||
.args([
|
||||
"store",
|
||||
"sign",
|
||||
"--key-file",
|
||||
&key_file.to_string_lossy(),
|
||||
store_path,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let re_output = Command::new("nix")
|
||||
.args(["path-info", "--json", store_path])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(o) = re_output {
|
||||
if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&o.stdout) {
|
||||
if let Some(sigs) = parsed
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|e| e.get("signatures"))
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
let sig_lines: Vec<String> = sigs
|
||||
.iter()
|
||||
.filter_map(|s| s.as_str())
|
||||
.map(|s| format!("Sig: {s}"))
|
||||
.collect();
|
||||
if !sig_lines.is_empty() {
|
||||
return format!("{narinfo}{}\n", sig_lines.join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
narinfo.to_string()
|
||||
}
|
||||
_ => narinfo.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a compressed NAR file for a store path.
|
||||
/// GET /nix-cache/nar/{hash}.nar.zst
|
||||
async fn serve_nar_zst(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !state.config.cache.enabled {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
let hash = hash
|
||||
.strip_suffix(".nar.zst")
|
||||
.or_else(|| hash.strip_suffix(".nar"))
|
||||
.unwrap_or(&hash);
|
||||
|
||||
if !fc_common::validate::is_valid_nix_hash(hash) {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
let product = sqlx::query_as::<_, fc_common::models::BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE path LIKE $1 LIMIT 1",
|
||||
)
|
||||
.bind(format!("/nix/store/{hash}-%"))
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
let product = match product {
|
||||
Some(p) => p,
|
||||
None => return Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
};
|
||||
|
||||
if !fc_common::validate::is_valid_store_path(&product.path) {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
// Use two piped processes instead of sh -c to prevent command injection
|
||||
let mut nix_child = std::process::Command::new("nix")
|
||||
.args(["store", "dump-path", &product.path])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|_| {
|
||||
ApiError(fc_common::CiError::Build(
|
||||
"Failed to start nix store dump-path".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
let nix_stdout = match nix_child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
};
|
||||
|
||||
let mut zstd_child = Command::new("zstd")
|
||||
.arg("-c")
|
||||
.stdin(nix_stdout)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|_| {
|
||||
ApiError(fc_common::CiError::Build(
|
||||
"Failed to start zstd compression".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
let zstd_stdout = match zstd_child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
};
|
||||
|
||||
let stream = tokio_util::io::ReaderStream::new(zstd_stdout);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
Ok((StatusCode::OK, [("content-type", "application/zstd")], body).into_response())
|
||||
}
|
||||
|
||||
/// Serve an uncompressed NAR file for a store path (legacy).
|
||||
/// GET /nix-cache/nar/{hash}.nar
|
||||
async fn serve_nar(
|
||||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !state.config.cache.enabled {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
let hash = hash.strip_suffix(".nar").unwrap_or(&hash);
|
||||
|
||||
if !fc_common::validate::is_valid_nix_hash(hash) {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
let product = sqlx::query_as::<_, fc_common::models::BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE path LIKE $1 LIMIT 1",
|
||||
)
|
||||
.bind(format!("/nix/store/{hash}-%"))
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
let product = match product {
|
||||
Some(p) => p,
|
||||
None => return Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
};
|
||||
|
||||
if !fc_common::validate::is_valid_store_path(&product.path) {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
let child = Command::new("nix")
|
||||
.args(["store", "dump-path", &product.path])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
};
|
||||
|
||||
let stdout = match child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
};
|
||||
|
||||
let stream = tokio_util::io::ReaderStream::new(stdout);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[("content-type", "application/x-nix-nar")],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Combined NAR handler — dispatches to zstd or plain based on suffix.
|
||||
/// GET /nix-cache/nar/{hash} where hash includes .nar.zst or .nar suffix
|
||||
async fn serve_nar_combined(
|
||||
state: State<AppState>,
|
||||
path: Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let hash_raw = path.0.clone();
|
||||
if hash_raw.ends_with(".nar.zst") {
|
||||
serve_nar_zst(state, path).await
|
||||
} else if hash_raw.ends_with(".nar") {
|
||||
serve_nar(state, path).await
|
||||
} else {
|
||||
Ok(StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
/// Nix binary cache info endpoint.
|
||||
/// GET /nix-cache/nix-cache-info
|
||||
async fn cache_info(State(state): State<AppState>) -> Response {
|
||||
if !state.config.cache.enabled {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
let info = "StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 30\n";
|
||||
|
||||
(StatusCode::OK, [("content-type", "text/plain")], info).into_response()
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/nix-cache/nix-cache-info", get(cache_info))
|
||||
.route("/nix-cache/{hash}", get(narinfo))
|
||||
.route("/nix-cache/nar/{hash}", get(serve_nar_combined))
|
||||
}
|
||||
89
crates/server/src/routes/channels.rs
Normal file
89
crates/server/src/routes/channels.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
};
|
||||
use fc_common::Validate;
|
||||
use fc_common::models::{Channel, CreateChannel};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireAdmin;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn list_channels(State(state): State<AppState>) -> Result<Json<Vec<Channel>>, ApiError> {
|
||||
let channels = fc_common::repo::channels::list_all(&state.pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(channels))
|
||||
}
|
||||
|
||||
async fn list_project_channels(
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<Channel>>, ApiError> {
|
||||
let channels = fc_common::repo::channels::list_for_project(&state.pool, project_id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(channels))
|
||||
}
|
||||
|
||||
async fn get_channel(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Channel>, ApiError> {
|
||||
let channel = fc_common::repo::channels::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(channel))
|
||||
}
|
||||
|
||||
async fn create_channel(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateChannel>,
|
||||
) -> Result<Json<Channel>, ApiError> {
|
||||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let channel = fc_common::repo::channels::create(&state.pool, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(channel))
|
||||
}
|
||||
|
||||
async fn delete_channel(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
fc_common::repo::channels::delete(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(serde_json::json!({"deleted": true})))
|
||||
}
|
||||
|
||||
async fn promote_channel(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, eval_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Channel>, ApiError> {
|
||||
let channel = fc_common::repo::channels::promote(&state.pool, channel_id, eval_id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(channel))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/channels", get(list_channels).post(create_channel))
|
||||
.route("/channels/{id}", get(get_channel).delete(delete_channel))
|
||||
.route(
|
||||
"/channels/{channel_id}/promote/{eval_id}",
|
||||
post(promote_channel),
|
||||
)
|
||||
.route(
|
||||
"/projects/{project_id}/channels",
|
||||
get(list_project_channels),
|
||||
)
|
||||
}
|
||||
978
crates/server/src/routes/dashboard.rs
Normal file
978
crates/server/src/routes/dashboard.rs
Normal file
|
|
@ -0,0 +1,978 @@
|
|||
use askama::Template;
|
||||
use axum::{
|
||||
Form, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::Extensions,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
};
|
||||
use fc_common::models::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// --- View models (pre-formatted for templates) ---
|
||||
|
||||
struct BuildView {
|
||||
id: Uuid,
|
||||
job_name: String,
|
||||
status_text: String,
|
||||
status_class: String,
|
||||
system: String,
|
||||
created_at: String,
|
||||
started_at: String,
|
||||
completed_at: String,
|
||||
duration: String,
|
||||
priority: i32,
|
||||
is_aggregate: bool,
|
||||
signed: bool,
|
||||
drv_path: String,
|
||||
output_path: String,
|
||||
error_message: String,
|
||||
log_url: String,
|
||||
}
|
||||
|
||||
struct EvalView {
|
||||
id: Uuid,
|
||||
commit_hash: String,
|
||||
commit_short: String,
|
||||
status_text: String,
|
||||
status_class: String,
|
||||
time: String,
|
||||
error_message: Option<String>,
|
||||
jobset_name: String,
|
||||
project_name: String,
|
||||
}
|
||||
|
||||
struct EvalSummaryView {
|
||||
id: Uuid,
|
||||
commit_short: String,
|
||||
status_text: String,
|
||||
status_class: String,
|
||||
time: String,
|
||||
succeeded: i64,
|
||||
failed: i64,
|
||||
pending: i64,
|
||||
}
|
||||
|
||||
struct ProjectSummaryView {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
jobset_count: i64,
|
||||
last_eval_status: String,
|
||||
last_eval_class: String,
|
||||
last_eval_time: String,
|
||||
}
|
||||
|
||||
struct ApiKeyView {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
role: String,
|
||||
created_at: String,
|
||||
last_used_at: String,
|
||||
}
|
||||
|
||||
fn format_duration(
|
||||
started: Option<&chrono::DateTime<chrono::Utc>>,
|
||||
completed: Option<&chrono::DateTime<chrono::Utc>>,
|
||||
) -> String {
|
||||
match (started, completed) {
|
||||
(Some(s), Some(c)) => {
|
||||
let secs = (*c - *s).num_seconds();
|
||||
if secs < 0 {
|
||||
return String::new();
|
||||
}
|
||||
let mins = secs / 60;
|
||||
let rem = secs % 60;
|
||||
if mins > 0 {
|
||||
format!("{mins}m {rem}s")
|
||||
} else {
|
||||
format!("{rem}s")
|
||||
}
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_view(b: &Build) -> BuildView {
|
||||
let (text, class) = status_badge(&b.status);
|
||||
BuildView {
|
||||
id: b.id,
|
||||
job_name: b.job_name.clone(),
|
||||
status_text: text,
|
||||
status_class: class,
|
||||
system: b.system.clone().unwrap_or_else(|| "-".to_string()),
|
||||
created_at: b.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||
started_at: b
|
||||
.started_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_default(),
|
||||
completed_at: b
|
||||
.completed_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_default(),
|
||||
duration: format_duration(b.started_at.as_ref(), b.completed_at.as_ref()),
|
||||
priority: b.priority,
|
||||
is_aggregate: b.is_aggregate,
|
||||
signed: b.signed,
|
||||
drv_path: b.drv_path.clone(),
|
||||
output_path: b.build_output_path.clone().unwrap_or_default(),
|
||||
error_message: b.error_message.clone().unwrap_or_default(),
|
||||
log_url: b.log_url.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_view(e: &Evaluation) -> EvalView {
|
||||
let (text, class) = eval_badge(&e.status);
|
||||
let short = if e.commit_hash.len() > 12 {
|
||||
e.commit_hash[..12].to_string()
|
||||
} else {
|
||||
e.commit_hash.clone()
|
||||
};
|
||||
EvalView {
|
||||
id: e.id,
|
||||
commit_hash: e.commit_hash.clone(),
|
||||
commit_short: short,
|
||||
status_text: text,
|
||||
status_class: class,
|
||||
time: e.evaluation_time.format("%Y-%m-%d %H:%M").to_string(),
|
||||
error_message: e.error_message.clone(),
|
||||
jobset_name: String::new(),
|
||||
project_name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_view_with_context(e: &Evaluation, jobset_name: &str, project_name: &str) -> EvalView {
|
||||
let mut v = eval_view(e);
|
||||
v.jobset_name = jobset_name.to_string();
|
||||
v.project_name = project_name.to_string();
|
||||
v
|
||||
}
|
||||
|
||||
fn status_badge(s: &BuildStatus) -> (String, String) {
|
||||
match s {
|
||||
BuildStatus::Completed => ("Completed".into(), "completed".into()),
|
||||
BuildStatus::Failed => ("Failed".into(), "failed".into()),
|
||||
BuildStatus::Running => ("Running".into(), "running".into()),
|
||||
BuildStatus::Pending => ("Pending".into(), "pending".into()),
|
||||
BuildStatus::Cancelled => ("Cancelled".into(), "cancelled".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_badge(s: &EvaluationStatus) -> (String, String) {
|
||||
match s {
|
||||
EvaluationStatus::Completed => ("Completed".into(), "completed".into()),
|
||||
EvaluationStatus::Failed => ("Failed".into(), "failed".into()),
|
||||
EvaluationStatus::Running => ("Running".into(), "running".into()),
|
||||
EvaluationStatus::Pending => ("Pending".into(), "pending".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_admin(extensions: &Extensions) -> bool {
|
||||
extensions
|
||||
.get::<ApiKey>()
|
||||
.map(|k| k.role == "admin")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn auth_name(extensions: &Extensions) -> String {
|
||||
extensions
|
||||
.get::<ApiKey>()
|
||||
.map(|k| k.name.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// --- Templates ---
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "home.html")]
|
||||
struct HomeTemplate {
|
||||
total_builds: i64,
|
||||
completed_builds: i64,
|
||||
failed_builds: i64,
|
||||
running_builds: i64,
|
||||
pending_builds: i64,
|
||||
recent_builds: Vec<BuildView>,
|
||||
recent_evals: Vec<EvalView>,
|
||||
projects: Vec<ProjectSummaryView>,
|
||||
is_admin: bool,
|
||||
auth_name: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "projects.html")]
|
||||
struct ProjectsTemplate {
|
||||
projects: Vec<Project>,
|
||||
limit: i64,
|
||||
has_prev: bool,
|
||||
has_next: bool,
|
||||
prev_offset: i64,
|
||||
next_offset: i64,
|
||||
page: i64,
|
||||
total_pages: i64,
|
||||
is_admin: bool,
|
||||
auth_name: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "project.html")]
|
||||
struct ProjectTemplate {
|
||||
project: Project,
|
||||
jobsets: Vec<Jobset>,
|
||||
recent_evals: Vec<EvalView>,
|
||||
is_admin: bool,
|
||||
auth_name: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "jobset.html")]
|
||||
struct JobsetTemplate {
|
||||
project: Project,
|
||||
jobset: Jobset,
|
||||
eval_summaries: Vec<EvalSummaryView>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "evaluations.html")]
|
||||
struct EvaluationsTemplate {
|
||||
evals: Vec<EvalView>,
|
||||
limit: i64,
|
||||
has_prev: bool,
|
||||
has_next: bool,
|
||||
prev_offset: i64,
|
||||
next_offset: i64,
|
||||
page: i64,
|
||||
total_pages: i64,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "evaluation.html")]
|
||||
struct EvaluationTemplate {
|
||||
eval: EvalView,
|
||||
builds: Vec<BuildView>,
|
||||
project_name: String,
|
||||
project_id: Uuid,
|
||||
jobset_name: String,
|
||||
jobset_id: Uuid,
|
||||
succeeded_count: i64,
|
||||
failed_count: i64,
|
||||
running_count: i64,
|
||||
pending_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "builds.html")]
|
||||
struct BuildsTemplate {
|
||||
builds: Vec<BuildView>,
|
||||
limit: i64,
|
||||
has_prev: bool,
|
||||
has_next: bool,
|
||||
prev_offset: i64,
|
||||
next_offset: i64,
|
||||
page: i64,
|
||||
total_pages: i64,
|
||||
filter_status: String,
|
||||
filter_system: String,
|
||||
filter_job: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "build.html")]
|
||||
struct BuildTemplate {
|
||||
build: BuildView,
|
||||
steps: Vec<BuildStep>,
|
||||
products: Vec<BuildProduct>,
|
||||
eval_id: Uuid,
|
||||
eval_commit_short: String,
|
||||
jobset_id: Uuid,
|
||||
jobset_name: String,
|
||||
project_id: Uuid,
|
||||
project_name: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "queue.html")]
|
||||
struct QueueTemplate {
|
||||
pending_builds: Vec<BuildView>,
|
||||
running_builds: Vec<BuildView>,
|
||||
pending_count: i64,
|
||||
running_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "channels.html")]
|
||||
struct ChannelsTemplate {
|
||||
channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin.html")]
|
||||
struct AdminTemplate {
|
||||
status: SystemStatus,
|
||||
builders: Vec<RemoteBuilder>,
|
||||
api_keys: Vec<ApiKeyView>,
|
||||
is_admin: bool,
|
||||
auth_name: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
async fn home(State(state): State<AppState>, extensions: Extensions) -> Html<String> {
|
||||
let stats = fc_common::repo::builds::get_stats(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let builds = fc_common::repo::builds::list_recent(&state.pool, 10)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let evals = fc_common::repo::evaluations::list_filtered(&state.pool, None, None, 5, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Fetch project summaries
|
||||
let all_projects = fc_common::repo::projects::list(&state.pool, 10, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut project_summaries = Vec::new();
|
||||
for p in &all_projects {
|
||||
let jobset_count = fc_common::repo::jobsets::count_for_project(&state.pool, p.id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(&state.pool, p.id, 100, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut last_eval: Option<Evaluation> = None;
|
||||
for js in &jobsets {
|
||||
let js_evals =
|
||||
fc_common::repo::evaluations::list_filtered(&state.pool, Some(js.id), None, 1, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if let Some(e) = js_evals.into_iter().next() {
|
||||
if last_eval
|
||||
.as_ref()
|
||||
.map_or(true, |le| e.evaluation_time > le.evaluation_time)
|
||||
{
|
||||
last_eval = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (status, class, time) = match &last_eval {
|
||||
Some(e) => {
|
||||
let (t, c) = eval_badge(&e.status);
|
||||
(t, c, e.evaluation_time.format("%Y-%m-%d %H:%M").to_string())
|
||||
}
|
||||
None => ("-".into(), "pending".into(), "-".into()),
|
||||
};
|
||||
project_summaries.push(ProjectSummaryView {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
jobset_count,
|
||||
last_eval_status: status,
|
||||
last_eval_class: class,
|
||||
last_eval_time: time,
|
||||
});
|
||||
}
|
||||
|
||||
let tmpl = HomeTemplate {
|
||||
total_builds: stats.total_builds.unwrap_or(0),
|
||||
completed_builds: stats.completed_builds.unwrap_or(0),
|
||||
failed_builds: stats.failed_builds.unwrap_or(0),
|
||||
running_builds: stats.running_builds.unwrap_or(0),
|
||||
pending_builds: stats.pending_builds.unwrap_or(0),
|
||||
recent_builds: builds.iter().map(build_view).collect(),
|
||||
recent_evals: evals.iter().map(eval_view).collect(),
|
||||
projects: project_summaries,
|
||||
is_admin: is_admin(&extensions),
|
||||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PageParams {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
async fn projects_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PageParams>,
|
||||
extensions: Extensions,
|
||||
) -> Html<String> {
|
||||
let limit = params.limit.unwrap_or(50).min(200).max(1);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
let items = fc_common::repo::projects::list(&state.pool, limit, offset)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let total = fc_common::repo::projects::count(&state.pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_pages = (total + limit - 1) / limit.max(1);
|
||||
let page = offset / limit.max(1) + 1;
|
||||
let tmpl = ProjectsTemplate {
|
||||
projects: items,
|
||||
limit,
|
||||
has_prev: offset > 0,
|
||||
has_next: offset + limit < total,
|
||||
prev_offset: (offset - limit).max(0),
|
||||
next_offset: offset + limit,
|
||||
page,
|
||||
total_pages,
|
||||
is_admin: is_admin(&extensions),
|
||||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn project_page(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
extensions: Extensions,
|
||||
) -> Html<String> {
|
||||
let project = match fc_common::repo::projects::get(&state.pool, id).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
};
|
||||
let jobsets = fc_common::repo::jobsets::list_for_project(&state.pool, id, 100, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get evaluations for this project's jobsets
|
||||
let mut evals = Vec::new();
|
||||
for js in &jobsets {
|
||||
let mut js_evals =
|
||||
fc_common::repo::evaluations::list_filtered(&state.pool, Some(js.id), None, 5, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
evals.append(&mut js_evals);
|
||||
}
|
||||
evals.sort_by(|a, b| b.evaluation_time.cmp(&a.evaluation_time));
|
||||
evals.truncate(10);
|
||||
|
||||
let tmpl = ProjectTemplate {
|
||||
project,
|
||||
jobsets,
|
||||
recent_evals: evals.iter().map(eval_view).collect(),
|
||||
is_admin: is_admin(&extensions),
|
||||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn jobset_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Html<String> {
|
||||
let jobset = match fc_common::repo::jobsets::get(&state.pool, id).await {
|
||||
Ok(j) => j,
|
||||
Err(_) => return Html("Jobset not found".to_string()),
|
||||
};
|
||||
let project = match fc_common::repo::projects::get(&state.pool, jobset.project_id).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
};
|
||||
|
||||
let evals = fc_common::repo::evaluations::list_filtered(&state.pool, Some(id), None, 20, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut summaries = Vec::new();
|
||||
for e in &evals {
|
||||
let (text, class) = eval_badge(&e.status);
|
||||
let short = if e.commit_hash.len() > 12 {
|
||||
e.commit_hash[..12].to_string()
|
||||
} else {
|
||||
e.commit_hash.clone()
|
||||
};
|
||||
let succeeded = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
Some(e.id),
|
||||
Some("completed"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let failed = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
Some(e.id),
|
||||
Some("failed"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let pending = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
Some(e.id),
|
||||
Some("pending"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
summaries.push(EvalSummaryView {
|
||||
id: e.id,
|
||||
commit_short: short,
|
||||
status_text: text,
|
||||
status_class: class,
|
||||
time: e.evaluation_time.format("%Y-%m-%d %H:%M").to_string(),
|
||||
succeeded,
|
||||
failed,
|
||||
pending,
|
||||
});
|
||||
}
|
||||
|
||||
let tmpl = JobsetTemplate {
|
||||
project,
|
||||
jobset,
|
||||
eval_summaries: summaries,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn evaluations_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PageParams>,
|
||||
) -> Html<String> {
|
||||
let limit = params.limit.unwrap_or(50).min(200).max(1);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
let items = fc_common::repo::evaluations::list_filtered(&state.pool, None, None, limit, offset)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let total = fc_common::repo::evaluations::count_filtered(&state.pool, None, None)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
// Enrich evaluations with jobset/project names
|
||||
let mut enriched = Vec::new();
|
||||
for e in &items {
|
||||
let (jname, pname) = match fc_common::repo::jobsets::get(&state.pool, e.jobset_id).await {
|
||||
Ok(js) => {
|
||||
let pname = fc_common::repo::projects::get(&state.pool, js.project_id)
|
||||
.await
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|_| "-".to_string());
|
||||
(js.name, pname)
|
||||
}
|
||||
Err(_) => ("-".to_string(), "-".to_string()),
|
||||
};
|
||||
enriched.push(eval_view_with_context(e, &jname, &pname));
|
||||
}
|
||||
|
||||
let total_pages = (total + limit - 1) / limit.max(1);
|
||||
let page = offset / limit.max(1) + 1;
|
||||
let tmpl = EvaluationsTemplate {
|
||||
evals: enriched,
|
||||
limit,
|
||||
has_prev: offset > 0,
|
||||
has_next: offset + limit < total,
|
||||
prev_offset: (offset - limit).max(0),
|
||||
next_offset: offset + limit,
|
||||
page,
|
||||
total_pages,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn evaluation_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Html<String> {
|
||||
let eval = match fc_common::repo::evaluations::get(&state.pool, id).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Html("Evaluation not found".to_string()),
|
||||
};
|
||||
|
||||
let jobset = match fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await {
|
||||
Ok(j) => j,
|
||||
Err(_) => return Html("Jobset not found".to_string()),
|
||||
};
|
||||
let project = match fc_common::repo::projects::get(&state.pool, jobset.project_id).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
};
|
||||
|
||||
let builds =
|
||||
fc_common::repo::builds::list_filtered(&state.pool, Some(id), None, None, None, 200, 0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let succeeded = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
Some(id),
|
||||
Some("completed"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let failed =
|
||||
fc_common::repo::builds::count_filtered(&state.pool, Some(id), Some("failed"), None, None)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let running =
|
||||
fc_common::repo::builds::count_filtered(&state.pool, Some(id), Some("running"), None, None)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let pending =
|
||||
fc_common::repo::builds::count_filtered(&state.pool, Some(id), Some("pending"), None, None)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let tmpl = EvaluationTemplate {
|
||||
eval: eval_view(&eval),
|
||||
builds: builds.iter().map(build_view).collect(),
|
||||
project_name: project.name,
|
||||
project_id: project.id,
|
||||
jobset_name: jobset.name,
|
||||
jobset_id: jobset.id,
|
||||
succeeded_count: succeeded,
|
||||
failed_count: failed,
|
||||
running_count: running,
|
||||
pending_count: pending,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BuildFilterParams {
|
||||
status: Option<String>,
|
||||
system: Option<String>,
|
||||
job_name: Option<String>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
async fn builds_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<BuildFilterParams>,
|
||||
) -> Html<String> {
|
||||
let limit = params.limit.unwrap_or(50).min(200).max(1);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
let items = fc_common::repo::builds::list_filtered(
|
||||
&state.pool,
|
||||
None,
|
||||
params.status.as_deref(),
|
||||
params.system.as_deref(),
|
||||
params.job_name.as_deref(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let total = fc_common::repo::builds::count_filtered(
|
||||
&state.pool,
|
||||
None,
|
||||
params.status.as_deref(),
|
||||
params.system.as_deref(),
|
||||
params.job_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let total_pages = (total + limit - 1) / limit.max(1);
|
||||
let page = offset / limit.max(1) + 1;
|
||||
let tmpl = BuildsTemplate {
|
||||
builds: items.iter().map(build_view).collect(),
|
||||
limit,
|
||||
has_prev: offset > 0,
|
||||
has_next: offset + limit < total,
|
||||
prev_offset: (offset - limit).max(0),
|
||||
next_offset: offset + limit,
|
||||
page,
|
||||
total_pages,
|
||||
filter_status: params.status.unwrap_or_default(),
|
||||
filter_system: params.system.unwrap_or_default(),
|
||||
filter_job: params.job_name.unwrap_or_default(),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn build_page(State(state): State<AppState>, Path(id): Path<Uuid>) -> Html<String> {
|
||||
let build = match fc_common::repo::builds::get(&state.pool, id).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Html("Build not found".to_string()),
|
||||
};
|
||||
|
||||
let eval = match fc_common::repo::evaluations::get(&state.pool, build.evaluation_id).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Html("Evaluation not found".to_string()),
|
||||
};
|
||||
let jobset = match fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await {
|
||||
Ok(j) => j,
|
||||
Err(_) => return Html("Jobset not found".to_string()),
|
||||
};
|
||||
let project = match fc_common::repo::projects::get(&state.pool, jobset.project_id).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
};
|
||||
|
||||
let eval_commit_short = if eval.commit_hash.len() > 12 {
|
||||
eval.commit_hash[..12].to_string()
|
||||
} else {
|
||||
eval.commit_hash.clone()
|
||||
};
|
||||
|
||||
let steps = fc_common::repo::build_steps::list_for_build(&state.pool, id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let products = fc_common::repo::build_products::list_for_build(&state.pool, id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let tmpl = BuildTemplate {
|
||||
build: build_view(&build),
|
||||
steps,
|
||||
products,
|
||||
eval_id: eval.id,
|
||||
eval_commit_short,
|
||||
jobset_id: jobset.id,
|
||||
jobset_name: jobset.name,
|
||||
project_id: project.id,
|
||||
project_name: project.name,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn queue_page(State(state): State<AppState>) -> Html<String> {
|
||||
let running = fc_common::repo::builds::list_filtered(
|
||||
&state.pool,
|
||||
None,
|
||||
Some("running"),
|
||||
None,
|
||||
None,
|
||||
100,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let pending = fc_common::repo::builds::list_filtered(
|
||||
&state.pool,
|
||||
None,
|
||||
Some("pending"),
|
||||
None,
|
||||
None,
|
||||
100,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let running_count = running.len() as i64;
|
||||
let pending_count = pending.len() as i64;
|
||||
|
||||
let tmpl = QueueTemplate {
|
||||
running_builds: running.iter().map(build_view).collect(),
|
||||
pending_builds: pending.iter().map(build_view).collect(),
|
||||
running_count,
|
||||
pending_count,
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn channels_page(State(state): State<AppState>) -> Html<String> {
|
||||
let channels = fc_common::repo::channels::list_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let tmpl = ChannelsTemplate { channels };
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
async fn admin_page(State(state): State<AppState>, extensions: Extensions) -> Html<String> {
|
||||
let pool = &state.pool;
|
||||
|
||||
let projects: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let jobsets: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobsets")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let evaluations: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let stats = fc_common::repo::builds::get_stats(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let builders_count = fc_common::repo::remote_builders::count(pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let channels: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM channels")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
|
||||
let status = SystemStatus {
|
||||
projects_count: projects.0,
|
||||
jobsets_count: jobsets.0,
|
||||
evaluations_count: evaluations.0,
|
||||
builds_pending: stats.pending_builds.unwrap_or(0),
|
||||
builds_running: stats.running_builds.unwrap_or(0),
|
||||
builds_completed: stats.completed_builds.unwrap_or(0),
|
||||
builds_failed: stats.failed_builds.unwrap_or(0),
|
||||
remote_builders: builders_count,
|
||||
channels_count: channels.0,
|
||||
};
|
||||
let builders = fc_common::repo::remote_builders::list(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Fetch API keys for admin view
|
||||
let keys = fc_common::repo::api_keys::list(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let api_keys: Vec<ApiKeyView> = keys
|
||||
.into_iter()
|
||||
.map(|k| ApiKeyView {
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
role: k.role,
|
||||
created_at: k.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||
last_used_at: k
|
||||
.last_used_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Never".to_string()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tmpl = AdminTemplate {
|
||||
status,
|
||||
builders,
|
||||
api_keys,
|
||||
is_admin: is_admin(&extensions),
|
||||
auth_name: auth_name(&extensions),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Login / Logout ---
|
||||
|
||||
async fn login_page() -> Html<String> {
|
||||
let tmpl = LoginTemplate { error: None };
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LoginForm {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
async fn login_action(State(state): State<AppState>, Form(form): Form<LoginForm>) -> Response {
|
||||
let token = form.api_key.trim();
|
||||
if token.is_empty() {
|
||||
let tmpl = LoginTemplate {
|
||||
error: Some("API key is required".to_string()),
|
||||
};
|
||||
return Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
|
||||
match fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await {
|
||||
Ok(Some(api_key)) => {
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
state.sessions.insert(
|
||||
session_id.clone(),
|
||||
crate::state::SessionData {
|
||||
api_key,
|
||||
created_at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
let cookie = format!(
|
||||
"fc_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400",
|
||||
session_id
|
||||
);
|
||||
(
|
||||
[(axum::http::header::SET_COOKIE, cookie)],
|
||||
Redirect::to("/"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
let tmpl = LoginTemplate {
|
||||
error: Some("Invalid API key".to_string()),
|
||||
};
|
||||
Html(
|
||||
tmpl.render()
|
||||
.unwrap_or_else(|e| format!("Template error: {e}")),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn logout_action() -> Response {
|
||||
let cookie = "fc_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0";
|
||||
(
|
||||
[(axum::http::header::SET_COOKIE, cookie.to_string())],
|
||||
Redirect::to("/"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
let _ = state; // used by middleware layer in mod.rs
|
||||
Router::new()
|
||||
.route("/login", get(login_page).post(login_action))
|
||||
.route("/logout", axum::routing::post(logout_action))
|
||||
.route("/", get(home))
|
||||
.route("/projects", get(projects_page))
|
||||
.route("/project/{id}", get(project_page))
|
||||
.route("/jobset/{id}", get(jobset_page))
|
||||
.route("/evaluations", get(evaluations_page))
|
||||
.route("/evaluation/{id}", get(evaluation_page))
|
||||
.route("/builds", get(builds_page))
|
||||
.route("/build/{id}", get(build_page))
|
||||
.route("/queue", get(queue_page))
|
||||
.route("/channels", get(channels_page))
|
||||
.route("/admin", get(admin_page))
|
||||
}
|
||||
93
crates/server/src/routes/evaluations.rs
Normal file
93
crates/server/src/routes/evaluations.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::Extensions,
|
||||
routing::{get, post},
|
||||
};
|
||||
use fc_common::{CreateEvaluation, Evaluation, PaginatedResponse, PaginationParams, Validate};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireRoles;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListEvaluationsParams {
|
||||
jobset_id: Option<Uuid>,
|
||||
status: Option<String>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list_evaluations(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ListEvaluationsParams>,
|
||||
) -> Result<Json<PaginatedResponse<Evaluation>>, ApiError> {
|
||||
let pagination = PaginationParams {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
};
|
||||
let limit = pagination.limit();
|
||||
let offset = pagination.offset();
|
||||
let items = fc_common::repo::evaluations::list_filtered(
|
||||
&state.pool,
|
||||
params.jobset_id,
|
||||
params.status.as_deref(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let total = fc_common::repo::evaluations::count_filtered(
|
||||
&state.pool,
|
||||
params.jobset_id,
|
||||
params.status.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(PaginatedResponse {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_evaluation(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Evaluation>, ApiError> {
|
||||
let evaluation = fc_common::repo::evaluations::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(evaluation))
|
||||
}
|
||||
|
||||
async fn trigger_evaluation(
|
||||
extensions: Extensions,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateEvaluation>,
|
||||
) -> Result<Json<Evaluation>, ApiError> {
|
||||
RequireRoles::check(&extensions, &["eval-jobset"]).map_err(|s| {
|
||||
ApiError(if s == axum::http::StatusCode::FORBIDDEN {
|
||||
fc_common::CiError::Forbidden("Insufficient permissions".to_string())
|
||||
} else {
|
||||
fc_common::CiError::Unauthorized("Authentication required".to_string())
|
||||
})
|
||||
})?;
|
||||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let evaluation = fc_common::repo::evaluations::create(&state.pool, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(evaluation))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/evaluations", get(list_evaluations))
|
||||
.route("/evaluations/{id}", get(get_evaluation))
|
||||
.route("/evaluations/trigger", post(trigger_evaluation))
|
||||
}
|
||||
28
crates/server/src/routes/health.rs
Normal file
28
crates/server/src/routes/health.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
status: &'static str,
|
||||
database: bool,
|
||||
}
|
||||
|
||||
async fn health_check(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||
let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
let status = if db_ok { "ok" } else { "degraded" };
|
||||
|
||||
Json(HealthResponse {
|
||||
status,
|
||||
database: db_ok,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/health", get(health_check))
|
||||
}
|
||||
54
crates/server/src/routes/jobsets.rs
Normal file
54
crates/server/src/routes/jobsets.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
routing::get,
|
||||
};
|
||||
use fc_common::{Jobset, UpdateJobset, Validate};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::RequireAdmin;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn get_jobset(
|
||||
State(state): State<AppState>,
|
||||
Path((_project_id, id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Jobset>, ApiError> {
|
||||
let jobset = fc_common::repo::jobsets::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(jobset))
|
||||
}
|
||||
|
||||
async fn update_jobset(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Path((_project_id, id)): Path<(Uuid, Uuid)>,
|
||||
Json(input): Json<UpdateJobset>,
|
||||
) -> Result<Json<Jobset>, ApiError> {
|
||||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let jobset = fc_common::repo::jobsets::update(&state.pool, id, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(jobset))
|
||||
}
|
||||
|
||||
async fn delete_jobset(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Path((_project_id, id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
fc_common::repo::jobsets::delete(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route(
|
||||
"/projects/{project_id}/jobsets/{id}",
|
||||
get(get_jobset).put(update_jobset).delete(delete_jobset),
|
||||
)
|
||||
}
|
||||
126
crates/server/src/routes/logs.rs
Normal file
126
crates/server/src/routes/logs.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use axum::response::sse::{Event, KeepAlive};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response, Sse},
|
||||
routing::get,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn get_build_log(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Response, ApiError> {
|
||||
// Verify build exists
|
||||
let _build = fc_common::repo::builds::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let log_storage = fc_common::log_storage::LogStorage::new(state.config.logs.log_dir.clone())
|
||||
.map_err(|e| ApiError(fc_common::CiError::Io(e)))?;
|
||||
|
||||
match log_storage.read_log(&id) {
|
||||
Ok(Some(content)) => Ok((
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/plain; charset=utf-8")],
|
||||
content,
|
||||
)
|
||||
.into_response()),
|
||||
Ok(None) => Ok((StatusCode::NOT_FOUND, "No log available for this build").into_response()),
|
||||
Err(e) => Err(ApiError(fc_common::CiError::Io(e))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_build_log(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>>, ApiError> {
|
||||
let build = fc_common::repo::builds::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let log_storage = fc_common::log_storage::LogStorage::new(state.config.logs.log_dir.clone())
|
||||
.map_err(|e| ApiError(fc_common::CiError::Io(e)))?;
|
||||
|
||||
let active_path = log_storage.log_path_for_active(&id);
|
||||
let final_path = log_storage.log_path(&id);
|
||||
let pool = state.pool.clone();
|
||||
let build_id = build.id;
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
// Determine which file to read
|
||||
let path = if active_path.exists() {
|
||||
active_path.clone()
|
||||
} else if final_path.exists() {
|
||||
final_path.clone()
|
||||
} else {
|
||||
// Wait for the file to appear
|
||||
let mut found = false;
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
if active_path.exists() || final_path.exists() {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
yield Ok(Event::default().data("No log file available"));
|
||||
return;
|
||||
}
|
||||
if active_path.exists() { active_path.clone() } else { final_path.clone() }
|
||||
};
|
||||
|
||||
let file = match tokio::fs::File::open(&path).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
yield Ok(Event::default().data("Failed to open log file"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut line = String::new();
|
||||
let mut consecutive_empty = 0u32;
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => {
|
||||
// EOF — check if build is still running
|
||||
consecutive_empty += 1;
|
||||
if consecutive_empty > 5 {
|
||||
// Check build status
|
||||
if let Ok(b) = fc_common::repo::builds::get(&pool, build_id).await {
|
||||
if b.status != fc_common::models::BuildStatus::Running
|
||||
&& b.status != fc_common::models::BuildStatus::Pending {
|
||||
yield Ok(Event::default().event("done").data("Build completed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
consecutive_empty = 0;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Ok(_) => {
|
||||
consecutive_empty = 0;
|
||||
yield Ok(Event::default().data(line.trim_end()));
|
||||
}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/builds/{id}/log", get(get_build_log))
|
||||
.route("/builds/{id}/log/stream", get(stream_build_log))
|
||||
}
|
||||
188
crates/server/src/routes/metrics.rs
Normal file
188
crates/server/src/routes/metrics.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
||||
let stats = match fc_common::repo::builds::get_stats(&state.pool).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let eval_count: i64 = match sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM evaluations")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => row.0,
|
||||
Err(_) => 0,
|
||||
};
|
||||
|
||||
let eval_by_status: Vec<(String, i64)> =
|
||||
sqlx::query_as("SELECT status::text, COUNT(*) FROM evaluations GROUP BY status")
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let (project_count, channel_count, builder_count): (i64, i64, i64) = sqlx::query_as(
|
||||
"SELECT \
|
||||
(SELECT COUNT(*) FROM projects), \
|
||||
(SELECT COUNT(*) FROM channels), \
|
||||
(SELECT COUNT(*) FROM remote_builders WHERE enabled = true)",
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((0, 0, 0));
|
||||
|
||||
// Per-project build counts
|
||||
let per_project: Vec<(String, i64, i64)> = sqlx::query_as(
|
||||
"SELECT p.name, \
|
||||
COUNT(*) FILTER (WHERE b.status = 'completed'), \
|
||||
COUNT(*) FILTER (WHERE b.status = 'failed') \
|
||||
FROM builds b \
|
||||
JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id \
|
||||
JOIN projects p ON j.project_id = p.id \
|
||||
GROUP BY p.name",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build duration percentiles (single query)
|
||||
let (duration_p50, duration_p95, duration_p99): (Option<f64>, Option<f64>, Option<f64>) =
|
||||
sqlx::query_as(
|
||||
"SELECT \
|
||||
(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY \
|
||||
EXTRACT(EPOCH FROM (completed_at - started_at)))), \
|
||||
(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY \
|
||||
EXTRACT(EPOCH FROM (completed_at - started_at)))), \
|
||||
(PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY \
|
||||
EXTRACT(EPOCH FROM (completed_at - started_at)))) \
|
||||
FROM builds WHERE completed_at IS NOT NULL AND started_at IS NOT NULL",
|
||||
)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap_or((None, None, None));
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
// Build counts by status
|
||||
output.push_str("# HELP fc_builds_total Total number of builds by status\n");
|
||||
output.push_str("# TYPE fc_builds_total gauge\n");
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"completed\"}} {}\n",
|
||||
stats.completed_builds.unwrap_or(0)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"failed\"}} {}\n",
|
||||
stats.failed_builds.unwrap_or(0)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"running\"}} {}\n",
|
||||
stats.running_builds.unwrap_or(0)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"pending\"}} {}\n",
|
||||
stats.pending_builds.unwrap_or(0)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"all\"}} {}\n",
|
||||
stats.total_builds.unwrap_or(0)
|
||||
));
|
||||
|
||||
// Build duration stats
|
||||
output.push_str("\n# HELP fc_builds_avg_duration_seconds Average build duration in seconds\n");
|
||||
output.push_str("# TYPE fc_builds_avg_duration_seconds gauge\n");
|
||||
output.push_str(&format!(
|
||||
"fc_builds_avg_duration_seconds {:.2}\n",
|
||||
stats.avg_duration_seconds.unwrap_or(0.0)
|
||||
));
|
||||
|
||||
output.push_str("\n# HELP fc_builds_duration_seconds Build duration percentiles\n");
|
||||
output.push_str("# TYPE fc_builds_duration_seconds gauge\n");
|
||||
if let Some(p50) = duration_p50 {
|
||||
output.push_str(&format!(
|
||||
"fc_builds_duration_seconds{{quantile=\"0.5\"}} {p50:.2}\n"
|
||||
));
|
||||
}
|
||||
if let Some(p95) = duration_p95 {
|
||||
output.push_str(&format!(
|
||||
"fc_builds_duration_seconds{{quantile=\"0.95\"}} {p95:.2}\n"
|
||||
));
|
||||
}
|
||||
if let Some(p99) = duration_p99 {
|
||||
output.push_str(&format!(
|
||||
"fc_builds_duration_seconds{{quantile=\"0.99\"}} {p99:.2}\n"
|
||||
));
|
||||
}
|
||||
|
||||
// Evaluations
|
||||
output.push_str("\n# HELP fc_evaluations_total Total number of evaluations\n");
|
||||
output.push_str("# TYPE fc_evaluations_total gauge\n");
|
||||
output.push_str(&format!("fc_evaluations_total {}\n", eval_count));
|
||||
|
||||
output.push_str("\n# HELP fc_evaluations_by_status Evaluations by status\n");
|
||||
output.push_str("# TYPE fc_evaluations_by_status gauge\n");
|
||||
for (status, count) in &eval_by_status {
|
||||
output.push_str(&format!(
|
||||
"fc_evaluations_by_status{{status=\"{status}\"}} {count}\n"
|
||||
));
|
||||
}
|
||||
|
||||
// Queue depth (pending builds)
|
||||
output.push_str("\n# HELP fc_queue_depth Number of pending builds in queue\n");
|
||||
output.push_str("# TYPE fc_queue_depth gauge\n");
|
||||
output.push_str(&format!(
|
||||
"fc_queue_depth {}\n",
|
||||
stats.pending_builds.unwrap_or(0)
|
||||
));
|
||||
|
||||
// Infrastructure
|
||||
output.push_str("\n# HELP fc_projects_total Total number of projects\n");
|
||||
output.push_str("# TYPE fc_projects_total gauge\n");
|
||||
output.push_str(&format!("fc_projects_total {project_count}\n"));
|
||||
|
||||
output.push_str("\n# HELP fc_channels_total Total number of channels\n");
|
||||
output.push_str("# TYPE fc_channels_total gauge\n");
|
||||
output.push_str(&format!("fc_channels_total {channel_count}\n"));
|
||||
|
||||
output.push_str("\n# HELP fc_remote_builders_active Active remote builders\n");
|
||||
output.push_str("# TYPE fc_remote_builders_active gauge\n");
|
||||
output.push_str(&format!("fc_remote_builders_active {builder_count}\n"));
|
||||
|
||||
// Per-project build counts
|
||||
if !per_project.is_empty() {
|
||||
output.push_str("\n# HELP fc_project_builds_completed Completed builds per project\n");
|
||||
output.push_str("# TYPE fc_project_builds_completed gauge\n");
|
||||
for (name, completed, _) in &per_project {
|
||||
output.push_str(&format!(
|
||||
"fc_project_builds_completed{{project=\"{name}\"}} {completed}\n"
|
||||
));
|
||||
}
|
||||
output.push_str("\n# HELP fc_project_builds_failed Failed builds per project\n");
|
||||
output.push_str("# TYPE fc_project_builds_failed gauge\n");
|
||||
for (name, _, failed) in &per_project {
|
||||
output.push_str(&format!(
|
||||
"fc_project_builds_failed{{project=\"{name}\"}} {failed}\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/plain; version=0.0.4; charset=utf-8")],
|
||||
output,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/metrics", get(prometheus_metrics))
|
||||
}
|
||||
156
crates/server/src/routes/mod.rs
Normal file
156
crates/server/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod badges;
|
||||
pub mod builds;
|
||||
pub mod cache;
|
||||
pub mod channels;
|
||||
pub mod dashboard;
|
||||
pub mod evaluations;
|
||||
pub mod health;
|
||||
pub mod jobsets;
|
||||
pub mod logs;
|
||||
pub mod metrics;
|
||||
pub mod projects;
|
||||
pub mod search;
|
||||
pub mod webhooks;
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{HeaderValue, Request, StatusCode};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use dashmap::DashMap;
|
||||
use fc_common::config::ServerConfig;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::auth_middleware::{extract_session, require_api_key};
|
||||
use crate::state::AppState;
|
||||
|
||||
struct RateLimitState {
|
||||
requests: DashMap<IpAddr, Vec<Instant>>,
|
||||
_rps: u64,
|
||||
burst: u32,
|
||||
last_cleanup: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
async fn rate_limit_middleware(
|
||||
ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
|
||||
request: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let state = request.extensions().get::<Arc<RateLimitState>>().cloned();
|
||||
|
||||
if let Some(rl) = state {
|
||||
let ip = addr.ip();
|
||||
let now = Instant::now();
|
||||
let window = std::time::Duration::from_secs(1);
|
||||
|
||||
// Periodic cleanup of stale entries (every 60 seconds)
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let last = rl.last_cleanup.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if now_secs - last > 60 {
|
||||
if rl
|
||||
.last_cleanup
|
||||
.compare_exchange(
|
||||
last,
|
||||
now_secs,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
rl.requests.retain(|_, v| {
|
||||
v.retain(|t| now.duration_since(*t) < std::time::Duration::from_secs(10));
|
||||
!v.is_empty()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut entry = rl.requests.entry(ip).or_default();
|
||||
entry.retain(|t| now.duration_since(*t) < window);
|
||||
|
||||
if entry.len() >= rl.burst as usize {
|
||||
return StatusCode::TOO_MANY_REQUESTS.into_response();
|
||||
}
|
||||
|
||||
entry.push(now);
|
||||
drop(entry);
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub fn router(state: AppState, config: &ServerConfig) -> Router {
|
||||
let cors_layer = if config.cors_permissive {
|
||||
CorsLayer::permissive()
|
||||
} else if config.allowed_origins.is_empty() {
|
||||
CorsLayer::new()
|
||||
} else {
|
||||
let origins: Vec<HeaderValue> = config
|
||||
.allowed_origins
|
||||
.iter()
|
||||
.filter_map(|o| o.parse().ok())
|
||||
.collect();
|
||||
CorsLayer::new().allow_origin(AllowOrigin::list(origins))
|
||||
};
|
||||
|
||||
let mut app = Router::new()
|
||||
// Dashboard routes with session extraction middleware
|
||||
.merge(
|
||||
dashboard::router(state.clone()).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
extract_session,
|
||||
)),
|
||||
)
|
||||
.merge(health::router())
|
||||
.merge(cache::router())
|
||||
.merge(metrics::router())
|
||||
// Webhooks use their own HMAC auth, outside the API key gate
|
||||
.merge(webhooks::router())
|
||||
// API routes with Bearer token auth
|
||||
.nest(
|
||||
"/api/v1",
|
||||
Router::new()
|
||||
.merge(projects::router())
|
||||
.merge(jobsets::router())
|
||||
.merge(evaluations::router())
|
||||
.merge(builds::router())
|
||||
.merge(logs::router())
|
||||
.merge(auth::router())
|
||||
.merge(search::router())
|
||||
.merge(badges::router())
|
||||
.merge(channels::router())
|
||||
.merge(admin::router())
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_api_key,
|
||||
)),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(cors_layer)
|
||||
.layer(RequestBodyLimitLayer::new(config.max_body_size));
|
||||
|
||||
// Add rate limiting if configured
|
||||
if let (Some(rps), Some(burst)) = (config.rate_limit_rps, config.rate_limit_burst) {
|
||||
let rl_state = Arc::new(RateLimitState {
|
||||
requests: DashMap::new(),
|
||||
_rps: rps,
|
||||
burst,
|
||||
last_cleanup: std::sync::atomic::AtomicU64::new(0),
|
||||
});
|
||||
app = app
|
||||
.layer(axum::Extension(rl_state))
|
||||
.layer(middleware::from_fn(rate_limit_middleware));
|
||||
}
|
||||
|
||||
app.with_state(state)
|
||||
}
|
||||
166
crates/server/src/routes/projects.rs
Normal file
166
crates/server/src/routes/projects.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::Extensions,
|
||||
routing::get,
|
||||
};
|
||||
use fc_common::{
|
||||
CreateJobset, CreateProject, Jobset, PaginatedResponse, PaginationParams, Project,
|
||||
UpdateProject, Validate,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth_middleware::{RequireAdmin, RequireRoles};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
async fn list_projects(
|
||||
State(state): State<AppState>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> Result<Json<PaginatedResponse<Project>>, ApiError> {
|
||||
let limit = pagination.limit();
|
||||
let offset = pagination.offset();
|
||||
let items = fc_common::repo::projects::list(&state.pool, limit, offset)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let total = fc_common::repo::projects::count(&state.pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(PaginatedResponse {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn create_project(
|
||||
extensions: Extensions,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateProject>,
|
||||
) -> Result<Json<Project>, ApiError> {
|
||||
RequireRoles::check(&extensions, &["create-projects"]).map_err(|s| {
|
||||
ApiError(if s == axum::http::StatusCode::FORBIDDEN {
|
||||
fc_common::CiError::Forbidden("Insufficient permissions".to_string())
|
||||
} else {
|
||||
fc_common::CiError::Unauthorized("Authentication required".to_string())
|
||||
})
|
||||
})?;
|
||||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let project = fc_common::repo::projects::create(&state.pool, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(project))
|
||||
}
|
||||
|
||||
async fn get_project(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Project>, ApiError> {
|
||||
let project = fc_common::repo::projects::get(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(project))
|
||||
}
|
||||
|
||||
async fn update_project(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<UpdateProject>,
|
||||
) -> Result<Json<Project>, ApiError> {
|
||||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let project = fc_common::repo::projects::update(&state.pool, id, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(project))
|
||||
}
|
||||
|
||||
async fn delete_project(
|
||||
_auth: RequireAdmin,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
fc_common::repo::projects::delete(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
|
||||
async fn list_project_jobsets(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> Result<Json<PaginatedResponse<Jobset>>, ApiError> {
|
||||
let limit = pagination.limit();
|
||||
let offset = pagination.offset();
|
||||
let items = fc_common::repo::jobsets::list_for_project(&state.pool, id, limit, offset)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let total = fc_common::repo::jobsets::count_for_project(&state.pool, id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(PaginatedResponse {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateJobsetBody {
|
||||
name: String,
|
||||
nix_expression: String,
|
||||
enabled: Option<bool>,
|
||||
flake_mode: Option<bool>,
|
||||
check_interval: Option<i32>,
|
||||
}
|
||||
|
||||
async fn create_project_jobset(
|
||||
extensions: Extensions,
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<Uuid>,
|
||||
Json(body): Json<CreateJobsetBody>,
|
||||
) -> Result<Json<Jobset>, ApiError> {
|
||||
RequireRoles::check(&extensions, &["create-projects"]).map_err(|s| {
|
||||
ApiError(if s == axum::http::StatusCode::FORBIDDEN {
|
||||
fc_common::CiError::Forbidden("Insufficient permissions".to_string())
|
||||
} else {
|
||||
fc_common::CiError::Unauthorized("Authentication required".to_string())
|
||||
})
|
||||
})?;
|
||||
let input = CreateJobset {
|
||||
project_id,
|
||||
name: body.name,
|
||||
nix_expression: body.nix_expression,
|
||||
enabled: body.enabled,
|
||||
flake_mode: body.flake_mode,
|
||||
check_interval: body.check_interval,
|
||||
};
|
||||
input
|
||||
.validate()
|
||||
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||
let jobset = fc_common::repo::jobsets::create(&state.pool, input)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(jobset))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/projects", get(list_projects).post(create_project))
|
||||
.route(
|
||||
"/projects/{id}",
|
||||
get(get_project).put(update_project).delete(delete_project),
|
||||
)
|
||||
.route(
|
||||
"/projects/{id}/jobsets",
|
||||
get(list_project_jobsets).post(create_project_jobset),
|
||||
)
|
||||
}
|
||||
58
crates/server/src/routes/search.rs
Normal file
58
crates/server/src/routes/search.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
routing::get,
|
||||
};
|
||||
use fc_common::models::{Build, Project};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchParams {
|
||||
q: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SearchResults {
|
||||
projects: Vec<Project>,
|
||||
builds: Vec<Build>,
|
||||
}
|
||||
|
||||
async fn search(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> Result<Json<SearchResults>, ApiError> {
|
||||
let query = params.q.trim();
|
||||
if query.is_empty() || query.len() > 256 {
|
||||
return Ok(Json(SearchResults {
|
||||
projects: vec![],
|
||||
builds: vec![],
|
||||
}));
|
||||
}
|
||||
|
||||
let pattern = format!("%{query}%");
|
||||
|
||||
let projects = sqlx::query_as::<_, Project>(
|
||||
"SELECT * FROM projects WHERE name ILIKE $1 OR description ILIKE $1 ORDER BY name LIMIT 20",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
let builds = sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE job_name ILIKE $1 OR drv_path ILIKE $1 ORDER BY created_at DESC LIMIT 20",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
Ok(Json(SearchResults { projects, builds }))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/search", get(search))
|
||||
}
|
||||
302
crates/server/src/routes/webhooks.rs
Normal file
302
crates/server/src/routes/webhooks.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
use axum::{
|
||||
Json, Router,
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::post,
|
||||
};
|
||||
use fc_common::models::CreateEvaluation;
|
||||
use fc_common::repo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WebhookResponse {
|
||||
accepted: bool,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubPushPayload {
|
||||
#[serde(alias = "ref")]
|
||||
git_ref: Option<String>,
|
||||
after: Option<String>,
|
||||
repository: Option<GithubRepo>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubRepo {
|
||||
clone_url: Option<String>,
|
||||
html_url: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaPushPayload {
|
||||
#[serde(alias = "ref")]
|
||||
git_ref: Option<String>,
|
||||
after: Option<String>,
|
||||
repository: Option<GiteaRepo>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaRepo {
|
||||
clone_url: Option<String>,
|
||||
html_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Verify HMAC-SHA256 webhook signature.
|
||||
/// The `secret` parameter is the raw webhook secret stored in DB.
|
||||
fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(body);
|
||||
|
||||
// Parse the hex signature (strip "sha256=" prefix if present)
|
||||
let hex_sig = signature
|
||||
.strip_prefix("sha256=")
|
||||
.or_else(|| signature.strip_prefix("sha1="))
|
||||
.unwrap_or(signature);
|
||||
|
||||
let Ok(sig_bytes) = hex::decode(hex_sig) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
mac.verify_slice(&sig_bytes).is_ok()
|
||||
}
|
||||
|
||||
async fn handle_github_push(
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<(StatusCode, Json<WebhookResponse>), ApiError> {
|
||||
// Check webhook config exists
|
||||
let webhook_config =
|
||||
repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, "github")
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let webhook_config = match webhook_config {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(WebhookResponse {
|
||||
accepted: false,
|
||||
message: "No GitHub webhook configured for this project".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify signature if secret is configured
|
||||
if let Some(ref secret_hash) = webhook_config.secret_hash {
|
||||
let signature = headers
|
||||
.get("x-hub-signature-256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if !verify_signature(secret_hash, &body, signature) {
|
||||
return Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(WebhookResponse {
|
||||
accepted: false,
|
||||
message: "Invalid webhook signature".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
let payload: GithubPushPayload = serde_json::from_slice(&body).map_err(|e| {
|
||||
ApiError(fc_common::CiError::Validation(format!(
|
||||
"Invalid payload: {e}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let commit = payload.after.unwrap_or_default();
|
||||
if commit.is_empty() || commit == "0000000000000000000000000000000000000000" {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: "Branch deletion event, skipping".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Find matching jobsets for this project and trigger evaluations
|
||||
let jobsets = repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let mut triggered = 0;
|
||||
for jobset in &jobsets {
|
||||
if !jobset.enabled {
|
||||
continue;
|
||||
}
|
||||
match repo::evaluations::create(
|
||||
&state.pool,
|
||||
CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: commit.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => triggered += 1,
|
||||
Err(fc_common::CiError::Conflict(_)) => {} // already exists
|
||||
Err(e) => tracing::warn!("Failed to create evaluation: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: format!("Triggered {triggered} evaluations for commit {commit}"),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn handle_gitea_push(
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<(StatusCode, Json<WebhookResponse>), ApiError> {
|
||||
// Check webhook config exists
|
||||
let forge_type = if headers.get("x-forgejo-event").is_some() {
|
||||
"forgejo"
|
||||
} else {
|
||||
"gitea"
|
||||
};
|
||||
|
||||
let webhook_config =
|
||||
repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, forge_type)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
// Fall back to the other type if not found
|
||||
let webhook_config = match webhook_config {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let alt = if forge_type == "gitea" {
|
||||
"forgejo"
|
||||
} else {
|
||||
"gitea"
|
||||
};
|
||||
match repo::webhook_configs::get_by_project_and_forge(&state.pool, project_id, alt)
|
||||
.await
|
||||
.map_err(ApiError)?
|
||||
{
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(WebhookResponse {
|
||||
accepted: false,
|
||||
message: "No Gitea/Forgejo webhook configured for this project"
|
||||
.to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Verify signature if configured
|
||||
if let Some(ref secret_hash) = webhook_config.secret_hash {
|
||||
let signature = headers
|
||||
.get("x-gitea-signature")
|
||||
.or_else(|| headers.get("x-forgejo-signature"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if !verify_signature(secret_hash, &body, signature) {
|
||||
return Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(WebhookResponse {
|
||||
accepted: false,
|
||||
message: "Invalid webhook signature".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let payload: GiteaPushPayload = serde_json::from_slice(&body).map_err(|e| {
|
||||
ApiError(fc_common::CiError::Validation(format!(
|
||||
"Invalid payload: {e}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let commit = payload.after.unwrap_or_default();
|
||||
if commit.is_empty() || commit == "0000000000000000000000000000000000000000" {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: "Branch deletion event, skipping".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let jobsets = repo::jobsets::list_for_project(&state.pool, project_id, 1000, 0)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let mut triggered = 0;
|
||||
for jobset in &jobsets {
|
||||
if !jobset.enabled {
|
||||
continue;
|
||||
}
|
||||
match repo::evaluations::create(
|
||||
&state.pool,
|
||||
CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: commit.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => triggered += 1,
|
||||
Err(fc_common::CiError::Conflict(_)) => {}
|
||||
Err(e) => tracing::warn!("Failed to create evaluation: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: format!("Triggered {triggered} evaluations for commit {commit}"),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/webhooks/{project_id}/github",
|
||||
post(handle_github_push),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/webhooks/{project_id}/gitea",
|
||||
post(handle_gitea_push),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/webhooks/{project_id}/forgejo",
|
||||
post(handle_gitea_push),
|
||||
)
|
||||
}
|
||||
19
crates/server/src/state.rs
Normal file
19
crates/server/src/state.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use fc_common::config::Config;
|
||||
use fc_common::models::ApiKey;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct SessionData {
|
||||
pub api_key: ApiKey,
|
||||
pub created_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Config,
|
||||
pub sessions: Arc<DashMap<String, SessionData>>,
|
||||
}
|
||||
186
crates/server/static/style.css
Normal file
186
crates/server/static/style.css
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/* FC CI Dashboard Styles */
|
||||
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--fg: #1a1a1a;
|
||||
--border: #ddd;
|
||||
--accent: #2563eb;
|
||||
--muted: #6b7280;
|
||||
--card-bg: #fff;
|
||||
--green: #16a34a;
|
||||
--red: #dc2626;
|
||||
--yellow: #ca8a04;
|
||||
--gray: #6b7280;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
code {
|
||||
background: #f3f4f6;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.nav-links { display: flex; gap: 1rem; }
|
||||
.nav-links a { color: var(--muted); font-size: 0.9rem; }
|
||||
.nav-links a:hover { color: var(--fg); }
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 1.5rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1 { margin-bottom: 1rem; font-size: 1.5rem; }
|
||||
h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value { font-size: 1.75rem; font-weight: 700; }
|
||||
.stat-label { font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: #f9fafb; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge-completed { background: #dcfce7; color: var(--green); }
|
||||
.badge-failed { background: #fee2e2; color: var(--red); }
|
||||
.badge-running { background: #fef9c3; color: var(--yellow); }
|
||||
.badge-pending { background: #f3f4f6; color: var(--gray); }
|
||||
.badge-cancelled { background: #f3f4f6; color: var(--gray); }
|
||||
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.filter-form select,
|
||||
.filter-form input {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-form button {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-form button:hover { opacity: 0.9; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar { flex-direction: column; gap: 0.5rem; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.filter-form { flex-direction: column; }
|
||||
table { font-size: 0.85rem; }
|
||||
th, td { padding: 0.35rem 0.5rem; }
|
||||
}
|
||||
205
crates/server/templates/admin.html
Normal file
205
crates/server/templates/admin.html
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Admin - FC CI{% endblock %}
|
||||
{% block auth %}
|
||||
{% if !auth_name.is_empty() %}
|
||||
<span class="auth-user">{{ auth_name }}</span>
|
||||
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
|
||||
{% else %}
|
||||
<a href="/login">Login</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Administration</h1>
|
||||
|
||||
<h2>System Status</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-value">{{ status.projects_count }}</div><div class="stat-label">Projects</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.jobsets_count }}</div><div class="stat-label">Jobsets</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.evaluations_count }}</div><div class="stat-label">Evaluations</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.builds_pending }}</div><div class="stat-label">Pending</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.builds_running }}</div><div class="stat-label">Running</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.builds_completed }}</div><div class="stat-label">Completed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.builds_failed }}</div><div class="stat-label">Failed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.channels_count }}</div><div class="stat-label">Channels</div></div>
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<h2>API Keys</h2>
|
||||
<details>
|
||||
<summary>Create API Key</summary>
|
||||
<div class="form-card">
|
||||
<form id="create-key-form">
|
||||
<div class="form-group">
|
||||
<label for="key-name">Name</label>
|
||||
<input type="text" id="key-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="key-role">Role</label>
|
||||
<select id="key-role">
|
||||
<option value="admin">admin</option>
|
||||
<option value="read-only" selected>read-only</option>
|
||||
<option value="create-projects">create-projects</option>
|
||||
<option value="eval-jobset">eval-jobset</option>
|
||||
<option value="cancel-build">cancel-build</option>
|
||||
<option value="restart-jobs">restart-jobs</option>
|
||||
<option value="bump-to-front">bump-to-front</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Key</button>
|
||||
</form>
|
||||
<div id="key-msg"></div>
|
||||
</div>
|
||||
</details>
|
||||
{% if api_keys.is_empty() %}
|
||||
<p class="empty">No API keys.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Role</th><th>Created</th><th>Last Used</th>{% if is_admin %}<th>Actions</th>{% endif %}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k in api_keys %}
|
||||
<tr>
|
||||
<td>{{ k.name }}</td>
|
||||
<td><span class="badge badge-pending">{{ k.role }}</span></td>
|
||||
<td>{{ k.created_at }}</td>
|
||||
<td>{{ k.last_used_at }}</td>
|
||||
{% if is_admin %}
|
||||
<td><button class="btn btn-danger btn-small" onclick="deleteKey('{{ k.id }}')">Delete</button></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<h2>Remote Builders</h2>
|
||||
{% if is_admin %}
|
||||
<details>
|
||||
<summary>Add Remote Builder</summary>
|
||||
<div class="form-card">
|
||||
<form id="create-builder-form">
|
||||
<div class="form-group">
|
||||
<label for="builder-name">Name</label>
|
||||
<input type="text" id="builder-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="builder-ssh">SSH URI</label>
|
||||
<input type="text" id="builder-ssh" placeholder="ssh://builder@host" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="builder-systems">Systems (comma-separated)</label>
|
||||
<input type="text" id="builder-systems" value="x86_64-linux" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="builder-maxjobs">Max Jobs</label>
|
||||
<input type="number" id="builder-maxjobs" value="4">
|
||||
</div>
|
||||
<button type="submit" class="btn">Add Builder</button>
|
||||
</form>
|
||||
<div id="builder-msg"></div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if builders.is_empty() %}
|
||||
<p class="empty">No remote builders configured.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>SSH URI</th><th>Systems</th><th>Max Jobs</th><th>Enabled</th>{% if is_admin %}<th>Actions</th>{% endif %}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in builders %}
|
||||
<tr>
|
||||
<td>{{ b.name }}</td>
|
||||
<td>{{ b.ssh_uri }}</td>
|
||||
<td>{{ b.systems.join(", ") }}</td>
|
||||
<td>{{ b.max_jobs }}</td>
|
||||
<td>{% if b.enabled %}Yes{% else %}No{% endif %}</td>
|
||||
{% if is_admin %}
|
||||
<td>
|
||||
<button class="btn btn-small" onclick="toggleBuilder('{{ b.id }}', {{ !b.enabled }})">{% if b.enabled %}Disable{% else %}Enable{% endif %}</button>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteBuilder('{{ b.id }}')">Delete</button>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% if is_admin %}
|
||||
<script>
|
||||
document.getElementById('create-key-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('key-msg');
|
||||
try {
|
||||
const res = await fetch('/api/v1/api-keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('key-name').value,
|
||||
role: document.getElementById('key-role').value,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
msg.innerHTML = '<div class="flash-message flash-success">Key created: <code>' + data.key + '</code><br>Copy this now, it will not be shown again.</div>';
|
||||
setTimeout(() => window.location.reload(), 5000);
|
||||
} else {
|
||||
msg.innerHTML = '<div class="flash-message flash-error">' + (data.error || 'Error') + '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
|
||||
}
|
||||
});
|
||||
async function deleteKey(id) {
|
||||
if (!confirm('Delete this API key?')) return;
|
||||
const res = await fetch('/api/v1/api-keys/' + id, { method: 'DELETE' });
|
||||
if (res.ok) window.location.reload();
|
||||
else alert('Failed to delete key');
|
||||
}
|
||||
document.getElementById('create-builder-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('builder-msg');
|
||||
try {
|
||||
const res = await fetch('/api/v1/admin/builders', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('builder-name').value,
|
||||
ssh_uri: document.getElementById('builder-ssh').value,
|
||||
systems: document.getElementById('builder-systems').value.split(',').map(s => s.trim()),
|
||||
max_jobs: parseInt(document.getElementById('builder-maxjobs').value) || 4,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
msg.innerHTML = '<div class="flash-message flash-error">' + (data.error || 'Error') + '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
|
||||
}
|
||||
});
|
||||
async function toggleBuilder(id, enable) {
|
||||
const res = await fetch('/api/v1/admin/builders/' + id, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ enabled: enable }),
|
||||
});
|
||||
if (res.ok) window.location.reload();
|
||||
else alert('Failed to update builder');
|
||||
}
|
||||
async function deleteBuilder(id) {
|
||||
if (!confirm('Delete this builder?')) return;
|
||||
const res = await fetch('/api/v1/admin/builders/' + id, { method: 'DELETE' });
|
||||
if (res.ok) window.location.reload();
|
||||
else alert('Failed to delete builder');
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
403
crates/server/templates/base.html
Normal file
403
crates/server/templates/base.html
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}FC CI{% endblock %}</title>
|
||||
<style>
|
||||
/* FC CI Dashboard Styles */
|
||||
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--fg: #1a1a1a;
|
||||
--border: #ddd;
|
||||
--accent: #2563eb;
|
||||
--muted: #6b7280;
|
||||
--card-bg: #fff;
|
||||
--green: #16a34a;
|
||||
--red: #dc2626;
|
||||
--yellow: #ca8a04;
|
||||
--gray: #6b7280;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111827;
|
||||
--fg: #f3f4f6;
|
||||
--border: #374151;
|
||||
--accent: #60a5fa;
|
||||
--muted: #9ca3af;
|
||||
--card-bg: #1f2937;
|
||||
--green: #4ade80;
|
||||
--red: #f87171;
|
||||
--yellow: #fbbf24;
|
||||
--gray: #9ca3af;
|
||||
}
|
||||
|
||||
code { background: #374151; }
|
||||
th { background: #1f2937; }
|
||||
tbody tr:hover { background: #1f2937; }
|
||||
|
||||
.badge-completed { background: #064e3b; color: var(--green); }
|
||||
.badge-failed { background: #450a0a; color: var(--red); }
|
||||
.badge-running { background: #422006; color: var(--yellow); }
|
||||
.badge-pending { background: #1f2937; color: var(--gray); }
|
||||
.badge-cancelled { background: #1f2937; color: var(--gray); }
|
||||
|
||||
.flash-error { background: #450a0a; border-color: var(--red); color: var(--red); }
|
||||
.flash-success { background: #064e3b; border-color: var(--green); color: var(--green); }
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
code {
|
||||
background: #f3f4f6;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.nav-links { display: flex; gap: 1rem; flex: 1; }
|
||||
.nav-links a { color: var(--muted); font-size: 0.9rem; }
|
||||
.nav-links a:hover { color: var(--fg); }
|
||||
|
||||
.nav-auth { display: flex; gap: 0.75rem; align-items: center; font-size: 0.85rem; }
|
||||
.nav-auth .auth-user { color: var(--muted); }
|
||||
.nav-auth a { color: var(--accent); }
|
||||
.nav-auth form { display: inline; }
|
||||
.nav-auth button { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 0.85rem; }
|
||||
.nav-auth button:hover { text-decoration: underline; }
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 1.5rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1 { margin-bottom: 1rem; font-size: 1.5rem; }
|
||||
h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value { font-size: 1.75rem; font-weight: 700; }
|
||||
.stat-label { font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: #f9fafb; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge-completed { background: #dcfce7; color: var(--green); }
|
||||
.badge-failed { background: #fee2e2; color: var(--red); }
|
||||
.badge-running { background: #fef9c3; color: var(--yellow); }
|
||||
.badge-pending { background: #f3f4f6; color: var(--gray); }
|
||||
.badge-cancelled { background: #f3f4f6; color: var(--gray); }
|
||||
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.filter-form select,
|
||||
.filter-form input {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--card-bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.filter-form button {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-form button:hover { opacity: 0.9; }
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.breadcrumbs a { color: var(--accent); }
|
||||
.breadcrumbs .sep { color: var(--muted); }
|
||||
.breadcrumbs .current { color: var(--fg); font-weight: 600; }
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.35rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-grid dt { font-weight: 600; color: var(--muted); }
|
||||
.detail-grid dd { color: var(--fg); }
|
||||
|
||||
.step-success { color: var(--green); font-weight: 600; }
|
||||
.step-failure { color: var(--red); font-weight: 600; }
|
||||
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab-nav a {
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tab-nav a:hover { color: var(--fg); text-decoration: none; }
|
||||
.tab-nav a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
|
||||
.queue-summary {
|
||||
display: inline-flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.form-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.form-group textarea { min-height: 60px; resize: vertical; }
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn:hover { opacity: 0.9; text-decoration: none; }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background: #fef2f2;
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
background: #f0fdf4;
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
details {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar { flex-direction: column; gap: 0.5rem; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.filter-form { flex-direction: column; }
|
||||
table { font-size: 0.85rem; }
|
||||
th, td { padding: 0.35rem 0.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand"><a href="/">FC CI</a></div>
|
||||
<div class="nav-links">
|
||||
<a href="/projects">Projects</a>
|
||||
<a href="/evaluations">Evaluations</a>
|
||||
<a href="/builds">Builds</a>
|
||||
<a href="/queue">Queue</a>
|
||||
<a href="/channels">Channels</a>
|
||||
<a href="/admin">Admin</a>
|
||||
</div>
|
||||
<div class="nav-auth">
|
||||
{% block auth %}{% endblock %}
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% block breadcrumbs %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<p>FC CI — Nix-based continuous integration</p>
|
||||
</footer>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
97
crates/server/templates/build.html
Normal file
97
crates/server/templates/build.html
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Build {{ build.job_name }} - FC CI{% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<nav class="breadcrumbs">
|
||||
<a href="/">Home</a> <span class="sep">/</span>
|
||||
<a href="/project/{{ project_id }}">{{ project_name }}</a> <span class="sep">/</span>
|
||||
<a href="/jobset/{{ jobset_id }}">{{ jobset_name }}</a> <span class="sep">/</span>
|
||||
<a href="/evaluation/{{ eval_id }}">{{ eval_commit_short }}</a> <span class="sep">/</span>
|
||||
<span class="current">{{ build.job_name }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Build: {{ build.job_name }}</h1>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<dt>Status</dt>
|
||||
<dd><span class="badge badge-{{ build.status_class }}">{{ build.status_text }}</span></dd>
|
||||
<dt>System</dt>
|
||||
<dd>{{ build.system }}</dd>
|
||||
<dt>Derivation</dt>
|
||||
<dd><code>{{ build.drv_path }}</code></dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ build.created_at }}</dd>
|
||||
{% if !build.started_at.is_empty() %}
|
||||
<dt>Started</dt>
|
||||
<dd>{{ build.started_at }}</dd>
|
||||
{% endif %}
|
||||
{% if !build.completed_at.is_empty() %}
|
||||
<dt>Completed</dt>
|
||||
<dd>{{ build.completed_at }}</dd>
|
||||
{% endif %}
|
||||
{% if !build.duration.is_empty() %}
|
||||
<dt>Duration</dt>
|
||||
<dd>{{ build.duration }}</dd>
|
||||
{% endif %}
|
||||
<dt>Priority</dt>
|
||||
<dd>{{ build.priority }}</dd>
|
||||
<dt>Signed</dt>
|
||||
<dd>{% if build.signed %}Yes{% else %}No{% endif %}</dd>
|
||||
{% if build.is_aggregate %}
|
||||
<dt>Aggregate</dt>
|
||||
<dd>Yes</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if !build.output_path.is_empty() %}
|
||||
<p><strong>Output:</strong> <code>{{ build.output_path }}</code></p>
|
||||
{% endif %}
|
||||
{% if !build.error_message.is_empty() %}
|
||||
<p><strong>Error:</strong> {{ build.error_message }}</p>
|
||||
{% endif %}
|
||||
{% if !build.log_url.is_empty() %}
|
||||
<p><a href="{{ build.log_url }}">View log</a></p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Build Steps</h2>
|
||||
{% if steps.is_empty() %}
|
||||
<p class="empty">No steps recorded.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Command</th><th>Exit</th><th>Started</th><th>Completed</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in steps %}
|
||||
<tr>
|
||||
<td>{{ s.step_number }}</td>
|
||||
<td><code>{{ s.command }}</code></td>
|
||||
<td>{% match s.exit_code %}{% when Some with (0) %}<span class="step-success">0</span>{% when Some with (code) %}<span class="step-failure">{{ code }}</span>{% when None %}-{% endmatch %}</td>
|
||||
<td>{{ s.started_at.format("%H:%M:%S") }}</td>
|
||||
<td>{% match s.completed_at %}{% when Some with (t) %}{{ t.format("%H:%M:%S") }}{% when None %}-{% endmatch %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Build Products</h2>
|
||||
{% if products.is_empty() %}
|
||||
<p class="empty">No products recorded.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Path</th><th>Size</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in products %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td><code>{{ p.path }}</code></td>
|
||||
<td>{% match p.file_size %}{% when Some with (sz) %}{{ sz }} bytes{% when None %}-{% endmatch %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
47
crates/server/templates/builds.html
Normal file
47
crates/server/templates/builds.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Builds - FC CI{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Builds</h1>
|
||||
|
||||
<form method="get" action="/builds" class="filter-form">
|
||||
<label>Status: <select name="status">
|
||||
<option value="">All</option>
|
||||
<option value="pending" {% if filter_status == "pending" %}selected{% endif %}>Pending</option>
|
||||
<option value="running" {% if filter_status == "running" %}selected{% endif %}>Running</option>
|
||||
<option value="completed" {% if filter_status == "completed" %}selected{% endif %}>Completed</option>
|
||||
<option value="failed" {% if filter_status == "failed" %}selected{% endif %}>Failed</option>
|
||||
</select></label>
|
||||
<label>System: <input type="text" name="system" value="{{ filter_system }}" placeholder="e.g. x86_64-linux"></label>
|
||||
<label>Job: <input type="text" name="job_name" value="{{ filter_job }}" placeholder="job name"></label>
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
{% if builds.is_empty() %}
|
||||
<p class="empty">No builds match filters.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Job</th><th>Status</th><th>System</th><th>Created</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in builds %}
|
||||
<tr>
|
||||
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
|
||||
<td><span class="badge badge-{{ b.status_class }}">{{ b.status_text }}</span></td>
|
||||
<td>{{ b.system }}</td>
|
||||
<td>{{ b.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<div class="pagination">
|
||||
{% if has_prev %}
|
||||
<a href="/builds?offset={{ prev_offset }}&limit={{ limit }}&status={{ filter_status }}&system={{ filter_system }}&job_name={{ filter_job }}">« Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ total_pages }}</span>
|
||||
{% if has_next %}
|
||||
<a href="/builds?offset={{ next_offset }}&limit={{ limit }}&status={{ filter_status }}&system={{ filter_system }}&job_name={{ filter_job }}">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
30
crates/server/templates/channels.html
Normal file
30
crates/server/templates/channels.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Channels - FC CI{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Channels</h1>
|
||||
{% if channels.is_empty() %}
|
||||
<p class="empty">No channels configured.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Current Evaluation</th><th>Updated</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in channels %}
|
||||
<tr>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>
|
||||
{% match c.current_evaluation_id %}
|
||||
{% when Some with (eval_id) %}
|
||||
<a href="/evaluation/{{ eval_id }}">{{ eval_id }}</a>
|
||||
{% when None %}
|
||||
-
|
||||
{% endmatch %}
|
||||
</td>
|
||||
<td>{{ c.updated_at.format("%Y-%m-%d %H:%M") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
61
crates/server/templates/evaluation.html
Normal file
61
crates/server/templates/evaluation.html
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Evaluation {{ eval.commit_short }} - FC CI{% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<nav class="breadcrumbs">
|
||||
<a href="/">Home</a> <span class="sep">/</span>
|
||||
<a href="/project/{{ project_id }}">{{ project_name }}</a> <span class="sep">/</span>
|
||||
<a href="/jobset/{{ jobset_id }}">{{ jobset_name }}</a> <span class="sep">/</span>
|
||||
<span class="current">{{ eval.commit_short }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Evaluation {{ eval.commit_short }}</h1>
|
||||
<p><strong>Status:</strong> <span class="badge badge-{{ eval.status_class }}">{{ eval.status_text }}</span></p>
|
||||
<p><strong>Commit:</strong> <code>{{ eval.commit_hash }}</code></p>
|
||||
<p><strong>Time:</strong> {{ eval.time }}</p>
|
||||
{% match eval.error_message %}
|
||||
{% when Some with (err) %}
|
||||
<p><strong>Error:</strong> {{ err }}</p>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ succeeded_count }}</div>
|
||||
<div class="stat-label">Succeeded</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ failed_count }}</div>
|
||||
<div class="stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ running_count }}</div>
|
||||
<div class="stat-label">Running</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ pending_count }}</div>
|
||||
<div class="stat-label">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Builds</h2>
|
||||
{% if builds.is_empty() %}
|
||||
<p class="empty">No builds for this evaluation.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Job</th><th>Status</th><th>System</th><th>Created</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in builds %}
|
||||
<tr>
|
||||
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
|
||||
<td><span class="badge badge-{{ b.status_class }}">{{ b.status_text }}</span></td>
|
||||
<td>{{ b.system }}</td>
|
||||
<td>{{ b.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
34
crates/server/templates/evaluations.html
Normal file
34
crates/server/templates/evaluations.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Evaluations - FC CI{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Evaluations</h1>
|
||||
{% if evals.is_empty() %}
|
||||
<p class="empty">No evaluations yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Commit</th><th>Project</th><th>Jobset</th><th>Status</th><th>Time</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in evals %}
|
||||
<tr>
|
||||
<td><a href="/evaluation/{{ e.id }}">{{ e.commit_short }}</a></td>
|
||||
<td>{{ e.project_name }}</td>
|
||||
<td>{{ e.jobset_name }}</td>
|
||||
<td><span class="badge badge-{{ e.status_class }}">{{ e.status_text }}</span></td>
|
||||
<td>{{ e.time }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<div class="pagination">
|
||||
{% if has_prev %}
|
||||
<a href="/evaluations?offset={{ prev_offset }}&limit={{ limit }}">« Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ total_pages }}</span>
|
||||
{% if has_next %}
|
||||
<a href="/evaluations?offset={{ next_offset }}&limit={{ limit }}">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
100
crates/server/templates/home.html
Normal file
100
crates/server/templates/home.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}FC CI - Dashboard{% endblock %}
|
||||
{% block auth %}
|
||||
{% if !auth_name.is_empty() %}
|
||||
<span class="auth-user">{{ auth_name }}</span>
|
||||
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
|
||||
{% else %}
|
||||
<a href="/login">Login</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ total_builds }}</div>
|
||||
<div class="stat-label">Total Builds</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ completed_builds }}</div>
|
||||
<div class="stat-label">Completed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ failed_builds }}</div>
|
||||
<div class="stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ running_builds }}</div>
|
||||
<div class="stat-label">Running</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ pending_builds }}</div>
|
||||
<div class="stat-label">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="queue-summary">
|
||||
<a href="/queue">Queue: {{ pending_builds }} pending, {{ running_builds }} running</a>
|
||||
</p>
|
||||
|
||||
{% if !projects.is_empty() %}
|
||||
<h2>Projects Overview</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Project</th><th>Jobsets</th><th>Last Eval</th><th>Time</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in projects %}
|
||||
<tr>
|
||||
<td><a href="/project/{{ p.id }}">{{ p.name }}</a></td>
|
||||
<td>{{ p.jobset_count }}</td>
|
||||
<td><span class="badge badge-{{ p.last_eval_class }}">{{ p.last_eval_status }}</span></td>
|
||||
<td>{{ p.last_eval_time }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Recent Builds</h2>
|
||||
{% if recent_builds.is_empty() %}
|
||||
<p class="empty">No builds yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Job</th><th>Status</th><th>System</th><th>Created</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in recent_builds %}
|
||||
<tr>
|
||||
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
|
||||
<td><span class="badge badge-{{ b.status_class }}">{{ b.status_text }}</span></td>
|
||||
<td>{{ b.system }}</td>
|
||||
<td>{{ b.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Recent Evaluations</h2>
|
||||
{% if recent_evals.is_empty() %}
|
||||
<p class="empty">No evaluations yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Commit</th><th>Status</th><th>Time</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in recent_evals %}
|
||||
<tr>
|
||||
<td><a href="/evaluation/{{ e.id }}">{{ e.commit_short }}</a></td>
|
||||
<td><span class="badge badge-{{ e.status_class }}">{{ e.status_text }}</span></td>
|
||||
<td>{{ e.time }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
65
crates/server/templates/jobset.html
Normal file
65
crates/server/templates/jobset.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ jobset.name }} - FC CI{% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<nav class="breadcrumbs">
|
||||
<a href="/">Home</a> <span class="sep">/</span>
|
||||
<a href="/project/{{ project.id }}">{{ project.name }}</a> <span class="sep">/</span>
|
||||
<span class="current">{{ jobset.name }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ jobset.name }}</h1>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<dt>Expression</dt>
|
||||
<dd><code>{{ jobset.nix_expression }}</code></dd>
|
||||
<dt>Flake mode</dt>
|
||||
<dd>{% if jobset.flake_mode %}Yes{% else %}No{% endif %}</dd>
|
||||
<dt>Enabled</dt>
|
||||
<dd>{% if jobset.enabled %}Yes{% else %}No{% endif %}</dd>
|
||||
<dt>Check interval</dt>
|
||||
<dd>{{ jobset.check_interval }}s</dd>
|
||||
</dl>
|
||||
|
||||
{% if !eval_summaries.is_empty() %}
|
||||
<h2>Latest Evaluation</h2>
|
||||
{% let latest = eval_summaries[0] %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ latest.succeeded }}</div>
|
||||
<div class="stat-label">Succeeded</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ latest.failed }}</div>
|
||||
<div class="stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ latest.pending }}</div>
|
||||
<div class="stat-label">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Recent Evaluations</h2>
|
||||
{% if eval_summaries.is_empty() %}
|
||||
<p class="empty">No evaluations yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Commit</th><th>Status</th><th>Succeeded</th><th>Failed</th><th>Pending</th><th>Time</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in eval_summaries %}
|
||||
<tr>
|
||||
<td><a href="/evaluation/{{ e.id }}">{{ e.commit_short }}</a></td>
|
||||
<td><span class="badge badge-{{ e.status_class }}">{{ e.status_text }}</span></td>
|
||||
<td>{{ e.succeeded }}</td>
|
||||
<td>{{ e.failed }}</td>
|
||||
<td>{{ e.pending }}</td>
|
||||
<td>{{ e.time }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
19
crates/server/templates/login.html
Normal file
19
crates/server/templates/login.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Login - FC CI{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Login</h1>
|
||||
<div class="form-card">
|
||||
{% match error %}
|
||||
{% when Some with (msg) %}
|
||||
<div class="flash-message flash-error">{{ msg }}</div>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label for="api_key">API Key</label>
|
||||
<input type="password" id="api_key" name="api_key" placeholder="fc_..." required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
133
crates/server/templates/project.html
Normal file
133
crates/server/templates/project.html
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ project.name }} - FC CI{% endblock %}
|
||||
{% block auth %}
|
||||
{% if !auth_name.is_empty() %}
|
||||
<span class="auth-user">{{ auth_name }}</span>
|
||||
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
|
||||
{% else %}
|
||||
<a href="/login">Login</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<nav class="breadcrumbs">
|
||||
<a href="/">Home</a> <span class="sep">/</span>
|
||||
<span class="current">{{ project.name }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ project.name }}</h1>
|
||||
{% match project.description %}
|
||||
{% when Some with (desc) %}
|
||||
<p>{{ desc }}</p>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
<p><strong>Repository:</strong> {{ project.repository_url }}</p>
|
||||
<p><strong>Created:</strong> {{ project.created_at.format("%Y-%m-%d %H:%M") }}</p>
|
||||
|
||||
{% if is_admin %}
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<button class="btn btn-danger btn-small" onclick="deleteProject()">Delete Project</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Jobsets</h2>
|
||||
|
||||
{% if is_admin %}
|
||||
<details>
|
||||
<summary>Add Jobset</summary>
|
||||
<div class="form-card">
|
||||
<form id="create-jobset-form">
|
||||
<div class="form-group">
|
||||
<label for="js-name">Name</label>
|
||||
<input type="text" id="js-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="js-expr">Nix Expression</label>
|
||||
<input type="text" id="js-expr" value="." required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" id="js-flake" checked> Flake mode</label>
|
||||
</div>
|
||||
<button type="submit" class="btn">Add Jobset</button>
|
||||
</form>
|
||||
<div id="jobset-msg"></div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if jobsets.is_empty() %}
|
||||
<p class="empty">No jobsets configured.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Expression</th><th>Flake</th><th>Enabled</th><th>Interval</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j in jobsets %}
|
||||
<tr>
|
||||
<td><a href="/jobset/{{ j.id }}">{{ j.name }}</a></td>
|
||||
<td><code>{{ j.nix_expression }}</code></td>
|
||||
<td>{% if j.flake_mode %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if j.enabled %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ j.check_interval }}s</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Recent Evaluations</h2>
|
||||
{% if recent_evals.is_empty() %}
|
||||
<p class="empty">No evaluations yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Commit</th><th>Status</th><th>Time</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in recent_evals %}
|
||||
<tr>
|
||||
<td><a href="/evaluation/{{ e.id }}">{{ e.commit_short }}</a></td>
|
||||
<td><span class="badge badge-{{ e.status_class }}">{{ e.status_text }}</span></td>
|
||||
<td>{{ e.time }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% if is_admin %}
|
||||
<script>
|
||||
document.getElementById('create-jobset-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('jobset-msg');
|
||||
try {
|
||||
const res = await fetch('/api/v1/projects/{{ project.id }}/jobsets', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('js-name').value,
|
||||
nix_expression: document.getElementById('js-expr').value,
|
||||
flake_mode: document.getElementById('js-flake').checked,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
msg.innerHTML = '<div class="flash-message flash-error">' + (err.error || 'Error') + '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
|
||||
}
|
||||
});
|
||||
async function deleteProject() {
|
||||
if (!confirm('Delete this project and all its data?')) return;
|
||||
const res = await fetch('/api/v1/projects/{{ project.id }}', { method: 'DELETE' });
|
||||
if (res.ok) window.location.href = '/projects';
|
||||
else alert('Failed to delete project');
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
94
crates/server/templates/projects.html
Normal file
94
crates/server/templates/projects.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Projects - FC CI{% endblock %}
|
||||
{% block auth %}
|
||||
{% if !auth_name.is_empty() %}
|
||||
<span class="auth-user">{{ auth_name }}</span>
|
||||
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
|
||||
{% else %}
|
||||
<a href="/login">Login</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Projects</h1>
|
||||
|
||||
{% if is_admin %}
|
||||
<details>
|
||||
<summary>New Project</summary>
|
||||
<div class="form-card">
|
||||
<form id="create-project-form">
|
||||
<div class="form-group">
|
||||
<label for="project-name">Name</label>
|
||||
<input type="text" id="project-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="project-repo">Repository URL</label>
|
||||
<input type="url" id="project-repo" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="project-desc">Description</label>
|
||||
<textarea id="project-desc"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Project</button>
|
||||
</form>
|
||||
<div id="project-msg"></div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if projects.is_empty() %}
|
||||
<p class="empty">No projects yet.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Repository</th><th>Created</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in projects %}
|
||||
<tr>
|
||||
<td><a href="/project/{{ p.id }}">{{ p.name }}</a></td>
|
||||
<td>{{ p.repository_url }}</td>
|
||||
<td>{{ p.created_at.format("%Y-%m-%d %H:%M") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<div class="pagination">
|
||||
{% if has_prev %}
|
||||
<a href="/projects?offset={{ prev_offset }}&limit={{ limit }}">« Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ total_pages }}</span>
|
||||
{% if has_next %}
|
||||
<a href="/projects?offset={{ next_offset }}&limit={{ limit }}">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% if is_admin %}
|
||||
<script>
|
||||
document.getElementById('create-project-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById('project-msg');
|
||||
try {
|
||||
const res = await fetch('/api/v1/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('project-name').value,
|
||||
repository_url: document.getElementById('project-repo').value,
|
||||
description: document.getElementById('project-desc').value || null,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
msg.innerHTML = '<div class="flash-message flash-error">' + (err.error || 'Error') + '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
51
crates/server/templates/queue.html
Normal file
51
crates/server/templates/queue.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Queue - FC CI{% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<nav class="breadcrumbs">
|
||||
<a href="/">Home</a> <span class="sep">/</span>
|
||||
<span class="current">Queue</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Build Queue</h1>
|
||||
|
||||
<h2>Running ({{ running_count }})</h2>
|
||||
{% if running_builds.is_empty() %}
|
||||
<p class="empty">No builds currently running.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Job</th><th>System</th><th>Started</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in running_builds %}
|
||||
<tr>
|
||||
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
|
||||
<td>{{ b.system }}</td>
|
||||
<td>{{ b.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>Pending ({{ pending_count }})</h2>
|
||||
{% if pending_builds.is_empty() %}
|
||||
<p class="empty">No builds pending.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Job</th><th>System</th><th>Created</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in pending_builds %}
|
||||
<tr>
|
||||
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
|
||||
<td>{{ b.system }}</td>
|
||||
<td>{{ b.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
777
crates/server/tests/api_tests.rs
Normal file
777
crates/server/tests/api_tests.rs
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
//! Integration tests for API endpoints.
|
||||
//! Requires TEST_DATABASE_URL to be set.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping API test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
sqlx::migrate!("../common/migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
fn build_app(pool: sqlx::PgPool) -> axum::Router {
|
||||
let config = fc_common::config::Config::default();
|
||||
let server_config = config.server.clone();
|
||||
let state = fc_server::state::AppState {
|
||||
pool,
|
||||
config,
|
||||
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
|
||||
};
|
||||
fc_server::routes::router(state, &server_config)
|
||||
}
|
||||
|
||||
fn build_app_with_config(pool: sqlx::PgPool, config: fc_common::config::Config) -> axum::Router {
|
||||
let server_config = config.server.clone();
|
||||
let state = fc_server::state::AppState {
|
||||
pool,
|
||||
config,
|
||||
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
|
||||
};
|
||||
fc_server::routes::router(state, &server_config)
|
||||
}
|
||||
|
||||
// ---- Existing tests ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_endpoint() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["status"], "ok");
|
||||
assert_eq!(json["database"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_project_endpoints() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
// Create project
|
||||
let create_body = serde_json::json!({
|
||||
"name": format!("api-test-{}", uuid::Uuid::new_v4()),
|
||||
"repository_url": "https://github.com/test/repo",
|
||||
"description": "Test project"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/projects")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&create_body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let project: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let project_id = project["id"].as_str().unwrap();
|
||||
|
||||
// Get project
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/projects/{project_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// List projects
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/projects")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// Get non-existent project -> 404
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/projects/{fake_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// Delete project
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/v1/projects/{project_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builds_endpoints() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
// Stats endpoint
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/builds/stats")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// Recent endpoint
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/builds/recent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ---- Hardening tests ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_response_includes_error_code() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/projects/{fake_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
assert_eq!(json["error_code"], "NOT_FOUND");
|
||||
assert!(json["error"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_invalid_hash_returns_404() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut config = fc_common::config::Config::default();
|
||||
config.cache.enabled = true;
|
||||
let app = build_app_with_config(pool, config);
|
||||
|
||||
// Too short
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/tooshort.narinfo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// Contains uppercase
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEF.narinfo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// Contains special chars
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/abcdefghijklmnop!@#$%^&*()abcde.narinfo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// SQL injection attempt
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/'%20OR%201=1;%20DROP%20TABLE%20builds;--.narinfo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// Valid hash format but no matching product -> 404 (not error)
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_nar_invalid_hash_returns_404() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut config = fc_common::config::Config::default();
|
||||
config.cache.enabled = true;
|
||||
let app = build_app_with_config(pool, config);
|
||||
|
||||
// Invalid hash in NAR endpoint
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/nar/INVALID_HASH.nar.zst")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// Invalid hash in uncompressed NAR endpoint
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/nar/INVALID_HASH.nar")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_disabled_returns_404() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut config = fc_common::config::Config::default();
|
||||
config.cache.enabled = false;
|
||||
let app = build_app_with_config(pool, config);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/nix-cache-info")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/abcdefghijklmnopqrstuvwxyz012345.narinfo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_rejects_long_query() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
// Query over 256 chars should return empty results
|
||||
let long_query = "a".repeat(300);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/search?q={long_query}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["projects"], serde_json::json!([]));
|
||||
assert_eq!(json["builds"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_rejects_empty_query() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/search?q=")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["projects"], serde_json::json!([]));
|
||||
assert_eq!(json["builds"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_whitespace_only_query() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/search?q=%20%20%20")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["projects"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builds_list_with_system_filter() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
// Filter by system - should return 200 even with no results
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/builds?system=x86_64-linux")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["items"].is_array());
|
||||
assert!(json["total"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builds_list_with_job_name_filter() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/builds?job_name=hello")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(json["items"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_builds_list_combined_filters() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/builds?system=aarch64-linux&status=pending&job_name=foo")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_info_returns_correct_headers() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut config = fc_common::config::Config::default();
|
||||
config.cache.enabled = true;
|
||||
let app = build_app_with_config(pool, config);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nix-cache/nix-cache-info")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers().get("content-type").unwrap(),
|
||||
"text/plain"
|
||||
);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body_str.contains("StoreDir: /nix/store"));
|
||||
assert!(body_str.contains("WantMassQuery: 1"));
|
||||
assert!(body_str.contains("Priority: 30"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metrics_endpoint() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/metrics")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("text/plain")
|
||||
);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body_str.contains("fc_builds_total"));
|
||||
assert!(body_str.contains("fc_projects_total"));
|
||||
assert!(body_str.contains("fc_evaluations_total"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_nonexistent_build_returns_error_code() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/builds/{fake_id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["error_code"], "NOT_FOUND");
|
||||
assert!(json["error"].as_str().unwrap().contains("not found"));
|
||||
}
|
||||
|
||||
// ---- Validation tests ----
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_project_validation_rejects_invalid_name() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
// Name starting with dash
|
||||
let body = serde_json::json!({
|
||||
"name": "-bad-name",
|
||||
"repository_url": "https://github.com/test/repo"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/projects")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["error_code"], "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_project_validation_rejects_bad_url() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"name": "valid-name",
|
||||
"repository_url": "ftp://bad-protocol.com/repo"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/projects")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["error_code"], "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_project_validation_accepts_valid() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"name": format!("valid-project-{}", uuid::Uuid::new_v4()),
|
||||
"repository_url": "https://github.com/test/repo",
|
||||
"description": "A valid project"
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/projects")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
334
crates/server/tests/e2e_test.rs
Normal file
334
crates/server/tests/e2e_test.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
//! End-to-end integration test.
|
||||
//! Requires TEST_DATABASE_URL to be set.
|
||||
//! Tests the full flow: create project -> jobset -> evaluation -> builds.
|
||||
//!
|
||||
//! Nix-dependent steps are skipped if nix is not available.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use fc_common::models::*;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn get_pool() -> Option<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 pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
sqlx::migrate!("../common/migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_e2e_project_eval_build_flow() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 1. Create a project
|
||||
let project_name = format!("e2e-test-{}", uuid::Uuid::new_v4());
|
||||
let project = fc_common::repo::projects::create(
|
||||
&pool,
|
||||
CreateProject {
|
||||
name: project_name.clone(),
|
||||
description: Some("E2E test project".to_string()),
|
||||
repository_url: "https://github.com/test/e2e".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create project");
|
||||
|
||||
assert_eq!(project.name, project_name);
|
||||
|
||||
// 2. Create a jobset
|
||||
let jobset = fc_common::repo::jobsets::create(
|
||||
&pool,
|
||||
CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "default".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
enabled: Some(true),
|
||||
flake_mode: Some(true),
|
||||
check_interval: Some(300),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
assert_eq!(jobset.project_id, project.id);
|
||||
assert!(jobset.enabled);
|
||||
|
||||
// 3. Verify active jobsets include our new one
|
||||
let active = fc_common::repo::jobsets::list_active(&pool)
|
||||
.await
|
||||
.expect("list active");
|
||||
assert!(
|
||||
active.iter().any(|j| j.id == jobset.id),
|
||||
"new jobset should be in active list"
|
||||
);
|
||||
|
||||
// 4. Create an evaluation
|
||||
let eval = fc_common::repo::evaluations::create(
|
||||
&pool,
|
||||
CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: "e2e0000000000000000000000000000000000000".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create evaluation");
|
||||
|
||||
assert_eq!(eval.jobset_id, jobset.id);
|
||||
assert_eq!(eval.status, EvaluationStatus::Pending);
|
||||
|
||||
// 5. Mark evaluation as running
|
||||
fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Running, None)
|
||||
.await
|
||||
.expect("update eval status");
|
||||
|
||||
// 6. Create builds as if nix evaluation found jobs
|
||||
let build1 = fc_common::repo::builds::create(
|
||||
&pool,
|
||||
CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: "hello".to_string(),
|
||||
drv_path: "/nix/store/e2e000-hello.drv".to_string(),
|
||||
system: Some("x86_64-linux".to_string()),
|
||||
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-hello"})),
|
||||
is_aggregate: Some(false),
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create build 1");
|
||||
|
||||
let build2 = fc_common::repo::builds::create(
|
||||
&pool,
|
||||
CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: "world".to_string(),
|
||||
drv_path: "/nix/store/e2e000-world.drv".to_string(),
|
||||
system: Some("x86_64-linux".to_string()),
|
||||
outputs: Some(serde_json::json!({"out": "/nix/store/e2e000-world"})),
|
||||
is_aggregate: Some(false),
|
||||
constituents: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create build 2");
|
||||
|
||||
assert_eq!(build1.status, BuildStatus::Pending);
|
||||
assert_eq!(build2.status, BuildStatus::Pending);
|
||||
|
||||
// 7. Create build dependency (hello depends on world)
|
||||
fc_common::repo::build_dependencies::create(&pool, build1.id, build2.id)
|
||||
.await
|
||||
.expect("create dependency");
|
||||
|
||||
// 8. Verify dependency check: build1 deps NOT complete (world is still pending)
|
||||
let deps_complete = fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
|
||||
.await
|
||||
.expect("check deps");
|
||||
assert!(!deps_complete, "deps should NOT be complete yet");
|
||||
|
||||
// 9. Complete build2 (world)
|
||||
fc_common::repo::builds::start(&pool, build2.id)
|
||||
.await
|
||||
.expect("start build2");
|
||||
fc_common::repo::builds::complete(
|
||||
&pool,
|
||||
build2.id,
|
||||
BuildStatus::Completed,
|
||||
None,
|
||||
Some("/nix/store/e2e000-world"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("complete build2");
|
||||
|
||||
// 10. Now build1 deps should be complete
|
||||
let deps_complete = fc_common::repo::build_dependencies::all_deps_completed(&pool, build1.id)
|
||||
.await
|
||||
.expect("check deps again");
|
||||
assert!(deps_complete, "deps should be complete after build2 done");
|
||||
|
||||
// 11. Complete build1 (hello)
|
||||
fc_common::repo::builds::start(&pool, build1.id)
|
||||
.await
|
||||
.expect("start build1");
|
||||
|
||||
let step = fc_common::repo::build_steps::create(
|
||||
&pool,
|
||||
CreateBuildStep {
|
||||
build_id: build1.id,
|
||||
step_number: 1,
|
||||
command: "nix build /nix/store/e2e000-hello.drv".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create step");
|
||||
|
||||
fc_common::repo::build_steps::complete(&pool, step.id, 0, Some("built!"), None)
|
||||
.await
|
||||
.expect("complete step");
|
||||
|
||||
fc_common::repo::build_products::create(
|
||||
&pool,
|
||||
CreateBuildProduct {
|
||||
build_id: build1.id,
|
||||
name: "out".to_string(),
|
||||
path: "/nix/store/e2e000-hello".to_string(),
|
||||
sha256_hash: Some("abcdef1234567890".to_string()),
|
||||
file_size: Some(12345),
|
||||
content_type: None,
|
||||
is_directory: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create product");
|
||||
|
||||
fc_common::repo::builds::complete(
|
||||
&pool,
|
||||
build1.id,
|
||||
BuildStatus::Completed,
|
||||
None,
|
||||
Some("/nix/store/e2e000-hello"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("complete build1");
|
||||
|
||||
// 12. Mark evaluation as completed
|
||||
fc_common::repo::evaluations::update_status(&pool, eval.id, EvaluationStatus::Completed, None)
|
||||
.await
|
||||
.expect("complete eval");
|
||||
|
||||
// 13. Verify everything is in the expected state
|
||||
let final_eval = fc_common::repo::evaluations::get(&pool, eval.id)
|
||||
.await
|
||||
.expect("get eval");
|
||||
assert_eq!(final_eval.status, EvaluationStatus::Completed);
|
||||
|
||||
let final_build1 = fc_common::repo::builds::get(&pool, build1.id)
|
||||
.await
|
||||
.expect("get build1");
|
||||
assert_eq!(final_build1.status, BuildStatus::Completed);
|
||||
assert_eq!(
|
||||
final_build1.build_output_path.as_deref(),
|
||||
Some("/nix/store/e2e000-hello")
|
||||
);
|
||||
|
||||
let products = fc_common::repo::build_products::list_for_build(&pool, build1.id)
|
||||
.await
|
||||
.expect("list products");
|
||||
assert_eq!(products.len(), 1);
|
||||
assert_eq!(products[0].name, "out");
|
||||
|
||||
let steps = fc_common::repo::build_steps::list_for_build(&pool, build1.id)
|
||||
.await
|
||||
.expect("list steps");
|
||||
assert_eq!(steps.len(), 1);
|
||||
assert_eq!(steps[0].exit_code, Some(0));
|
||||
|
||||
// 14. Verify build stats reflect our changes
|
||||
let stats = fc_common::repo::builds::get_stats(&pool)
|
||||
.await
|
||||
.expect("get stats");
|
||||
assert!(stats.completed_builds.unwrap_or(0) >= 2);
|
||||
|
||||
// 15. Create a channel and verify it works
|
||||
let channel = fc_common::repo::channels::create(
|
||||
&pool,
|
||||
CreateChannel {
|
||||
project_id: project.id,
|
||||
name: "stable".to_string(),
|
||||
jobset_id: jobset.id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
|
||||
let channels = fc_common::repo::channels::list_all(&pool)
|
||||
.await
|
||||
.expect("list channels");
|
||||
assert!(channels.iter().any(|c| c.id == channel.id));
|
||||
|
||||
// 16. Test the HTTP API layer
|
||||
let config = fc_common::config::Config::default();
|
||||
let server_config = config.server.clone();
|
||||
let state = fc_server::state::AppState {
|
||||
pool: pool.clone(),
|
||||
config,
|
||||
sessions: std::sync::Arc::new(dashmap::DashMap::new()),
|
||||
};
|
||||
let app = fc_server::routes::router(state, &server_config);
|
||||
|
||||
// GET /health
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// GET /api/v1/projects/{id}
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/projects/{}", project.id))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// GET /api/v1/builds/{id}
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/builds/{}", build1.id))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
// GET / (dashboard)
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body_str.contains("Dashboard"));
|
||||
|
||||
// Clean up
|
||||
let _ = fc_common::repo::projects::delete(&pool, project.id).await;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue