solstice-ci/crates/logs-service/src/main.rs

144 lines
5.8 KiB
Rust
Raw Normal View History

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<String>,
}
#[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<chrono::Utc>,
pub stderr: bool,
pub line: String,
pub category: String,
pub level: Option<String>,
pub fields: Option<String>,
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<chrono::Utc>,
last_ts: chrono::DateTime<chrono::Utc>,
}
async fn list_logs(Path(request_id): Path<String>, axum::extract::State(state): axum::extract::State<AppState>) -> 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<Value>) = 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<LogCategorySummary> = 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<chrono::Utc> = row.try_get_by("first_ts").unwrap_or_else(|_| chrono::Utc::now());
let last_ts: chrono::DateTime<chrono::Utc> = 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<AppState>) -> 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() }
}
}