use axum::{extract::Path, http::StatusCode, response::{IntoResponse, Response}, routing::get, Json, Router}; use clap::Parser; use miette::{IntoDiagnostic as _, Result}; use sea_orm::{entity::prelude::*, Database, DatabaseConnection, QueryOrder, ColumnTrait, QueryFilter, Statement, DatabaseBackend, Value}; use sea_orm_migration::MigratorTrait; use serde::Serialize; use std::net::SocketAddr; use tracing::{info, warn}; use uuid::Uuid; #[derive(Parser, Debug)] #[command(name = "solstice-logs", version, about = "Solstice CI — Logs Service")] struct Opts { /// HTTP bind address #[arg(long, env = "HTTP_ADDR", default_value = "0.0.0.0:8082")] http_addr: String, /// Database URL #[arg(long, env = "DATABASE_URL")] database_url: String, /// OTLP endpoint (e.g., http://localhost:4317) #[arg(long, env = "OTEL_EXPORTER_OTLP_ENDPOINT")] otlp: Option, } #[derive(Clone)] struct AppState { db: DatabaseConnection } #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<()> { let _t = common::init_tracing("solstice-logs-service")?; let opts = Opts::parse(); let db = Database::connect(opts.database_url).await.into_diagnostic()?; migration::Migrator::up(&db, None).await.into_diagnostic()?; let state = AppState { db }; let router = Router::new() .route("/jobs/{request_id}/logs", get(list_logs)) .route("/jobs/{request_id}/logs/{category}", get(get_logs_by_category)) .with_state(state); let addr: SocketAddr = opts.http_addr.parse().expect("invalid HTTP_ADDR"); info!(%addr, "logs-service starting"); axum::serve( tokio::net::TcpListener::bind(addr).await.expect("bind"), router, ) .await .into_diagnostic() } mod job_logs { use super::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "job_logs")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub request_id: Uuid, #[sea_orm(primary_key, auto_increment = false)] pub seq: i64, pub ts: chrono::DateTime, pub stderr: bool, pub line: String, pub category: String, pub level: Option, pub fields: Option, pub has_error: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } #[derive(Serialize)] struct LogCategorySummary { category: String, count: i64, has_errors: bool, first_ts: chrono::DateTime, last_ts: chrono::DateTime, } async fn list_logs(Path(request_id): Path, axum::extract::State(state): axum::extract::State) -> Response { let Ok(id) = Uuid::parse_str(&request_id) else { return StatusCode::BAD_REQUEST.into_response(); }; // Query per-category summaries using backend-agnostic SQL + parameter binding let backend = state.db.get_database_backend(); let (sql, vals): (&str, Vec) = match backend { DatabaseBackend::Postgres => ( "SELECT category AS category, COUNT(*) AS count, MIN(ts) AS first_ts, MAX(ts) AS last_ts, MAX(has_error) AS has_errors FROM job_logs WHERE request_id = $1 GROUP BY category ORDER BY category", vec![Value::Uuid(Some(Box::new(id)))] ), _ => ( "SELECT category AS category, COUNT(*) AS count, MIN(ts) AS first_ts, MAX(ts) AS last_ts, MAX(has_error) AS has_errors FROM job_logs WHERE request_id = ? GROUP BY category ORDER BY category", vec![Value::Uuid(Some(Box::new(id)))] ), }; let stmt = Statement::from_sql_and_values(backend, sql, vals); let rows = match state.db.query_all(stmt).await.into_diagnostic() { Ok(r) => r, Err(e) => { warn!(error = %e, request_id = %id, "failed to query log categories"); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; let mut out: Vec = Vec::new(); for row in rows { let category: String = row.try_get_by("category").unwrap_or_else(|_| "default".into()); let count: i64 = row.try_get_by("count").unwrap_or(0); let first_ts: chrono::DateTime = row.try_get_by("first_ts").unwrap_or_else(|_| chrono::Utc::now()); let last_ts: chrono::DateTime = row.try_get_by("last_ts").unwrap_or_else(|_| chrono::Utc::now()); let has_errors: bool = row.try_get_by("has_errors").unwrap_or(false); out.push(LogCategorySummary { category, count, has_errors, first_ts, last_ts }); } Json(out).into_response() } async fn get_logs_by_category(Path((request_id, category)): Path<(String, String)>, axum::extract::State(state): axum::extract::State) -> Response { let Ok(id) = Uuid::parse_str(&request_id) else { return StatusCode::BAD_REQUEST.into_response(); }; let rows = job_logs::Entity::find() .filter(job_logs::Column::RequestId.eq(id)) .filter(job_logs::Column::Category.eq(category.clone())) .order_by_asc(job_logs::Column::Seq) .all(&state.db) .await .into_diagnostic(); match rows { Ok(items) if items.is_empty() => StatusCode::NOT_FOUND.into_response(), Ok(items) => { let mut text = String::new(); for r in items { if r.stderr || r.has_error || r.level.as_deref() == Some("error") { text.push_str("[stderr] "); } text.push_str(&r.line); if !text.ends_with('\n') { text.push('\n'); } } ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], text, ).into_response() } Err(e) => { warn!(error = %e, request_id = %id, "failed to read logs"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } } }