1use std::path::Path;
17use std::path::PathBuf;
18
19use diesel::prelude::*;
20use figment::Figment;
21use figment::providers::Serialized;
22use rocket::form::Form;
23use rocket::http::Status;
24use rocket::response::Redirect;
25use rocket::serde::json::Json;
26use rocket::{Build, Rocket, Route, State};
27use rocket_dyn_templates::{Template, context};
28use serde::Serialize;
29
30use crate::backend::DbPool;
31use crate::config::{AssetsConfig, CortexConfig, DispatcherConfig, JobsConfig, config};
32use crate::frontend::actor::{
33 Actor, AdminReject, AdminSession, ReturnTo, require_admin, require_admin_to,
34};
35
36pub struct ConfigFile(pub PathBuf);
38
39#[derive(Debug, Serialize, schemars::JsonSchema)]
41pub struct DatabaseDto {
42 pub url: String,
44}
45
46#[derive(Debug, Serialize, schemars::JsonSchema)]
48pub struct AuthDto {
49 pub rerun_token_count: usize,
51}
52
53#[derive(Debug, Serialize, schemars::JsonSchema)]
55pub struct ConfigDto {
56 pub database: DatabaseDto,
58 pub dispatcher: DispatcherConfig,
60 pub assets: AssetsConfig,
62 pub jobs: JobsConfig,
64 pub auth: AuthDto,
66 pub webauthn: crate::config::WebauthnConfig,
68}
69
70impl ConfigDto {
71 pub fn from_config(cfg: &CortexConfig) -> ConfigDto {
73 ConfigDto {
74 database: DatabaseDto {
75 url: mask_db_password(&cfg.database.url),
76 },
77 dispatcher: cfg.dispatcher.clone(),
78 assets: cfg.assets.clone(),
79 jobs: cfg.jobs.clone(),
80 auth: AuthDto {
81 rerun_token_count: cfg.auth.rerun_tokens.len(),
82 },
83 webauthn: cfg.webauthn.clone(),
84 }
85 }
86}
87
88#[derive(Debug, Serialize, schemars::JsonSchema)]
90pub struct DbHealth {
91 pub reachable: bool,
93}
94
95#[derive(Debug, Serialize, schemars::JsonSchema)]
97pub struct MigrationsHealth {
98 pub current: bool,
100}
101
102#[derive(Debug, Serialize, schemars::JsonSchema)]
105pub struct PoolHealth {
106 pub max: u32,
108 pub connections: u32,
110 pub idle: u32,
112 pub in_use: u32,
114}
115
116#[derive(Debug, Serialize, schemars::JsonSchema)]
121pub struct DispatcherHealth {
122 pub reachable: bool,
124 pub source_port: usize,
126 pub result_port: usize,
128}
129
130#[derive(Debug, Serialize, schemars::JsonSchema)]
133pub struct UnreadableCorpus {
134 pub name: String,
136 pub path: String,
138}
139
140#[derive(Debug, Serialize, schemars::JsonSchema)]
147pub struct StorageHealth {
148 pub corpora_checked: usize,
150 pub unreadable: Vec<UnreadableCorpus>,
152}
153
154#[derive(Debug, Serialize, schemars::JsonSchema)]
156pub struct HealthDto {
157 pub status: &'static str,
160 pub database: DbHealth,
162 pub migrations: MigrationsHealth,
164 pub pool: PoolHealth,
166 pub dispatcher: DispatcherHealth,
168 pub storage: StorageHealth,
170 pub remediations: Vec<String>,
175}
176
177#[derive(Debug, Serialize, schemars::JsonSchema)]
183pub struct LivenessDto {
184 pub status: &'static str,
186 pub database: DbHealth,
188}
189
190impl HealthDto {
191 #[must_use]
196 pub fn remediations(&self) -> Vec<String> {
197 let mut hints = Vec::new();
198 if !self.database.reachable {
199 hints.push(
200 "database unreachable — the frontend is degraded; check the database URL (`cortex.toml` \
201 [database].url or DATABASE_URL) and that PostgreSQL is running"
202 .to_string(),
203 );
204 } else if !self.migrations.current {
205 hints
206 .push("schema out of date — run `cortex init` to apply the pending migrations".to_string());
207 }
208 if self.pool.in_use >= self.pool.max {
211 hints.push(format!(
212 "connection pool exhausted ({}/{} in use) — requests are waiting and may 503; raise \
213 `database.pool_size` or investigate slow / long-held queries",
214 self.pool.in_use, self.pool.max
215 ));
216 }
217 if !self.dispatcher.reachable {
218 hints.push(format!(
219 "dispatcher not listening on localhost:{}/{} — if this node runs conversions, start the \
220 dispatcher; a report-only frontend can ignore this",
221 self.dispatcher.source_port, self.dispatcher.result_port
222 ));
223 }
224 if !self.storage.unreadable.is_empty() {
225 let names: Vec<&str> = self
226 .storage
227 .unreadable
228 .iter()
229 .map(|corpus| corpus.name.as_str())
230 .collect();
231 hints.push(format!(
232 "{} corpus source path(s) unreadable ({}) — check the mount / permissions; conversions and \
233 re-imports for them fail until restored",
234 self.storage.unreadable.len(),
235 names.join(", ")
236 ));
237 }
238 hints
239 }
240}
241
242#[derive(FromForm)]
244pub struct SettingsForm {
245 pub dispatcher_source_port: usize,
247 pub dispatcher_result_port: usize,
249 pub dispatcher_queue_size: usize,
251 pub dispatcher_message_size: usize,
253 pub dispatcher_max_in_flight: usize,
255 pub dispatcher_max_result_bytes: usize,
258 pub dispatcher_report_refresh_interval_seconds: u64,
261 pub jobs_stale_timeout_seconds: i64,
263 pub assets_template_dir: String,
265 pub assets_public_dir: String,
267}
268
269fn mask_db_password(url: &str) -> String {
271 if let (Some(scheme_end), Some(at)) = (url.find("://"), url.find('@')) {
272 let creds = &url[scheme_end + 3..at];
273 if let Some(colon) = creds.find(':') {
274 let user = &creds[..colon];
275 return format!("{}://{}:***{}", &url[..scheme_end], user, &url[at..]);
276 }
277 }
278 url.to_string()
279}
280
281fn merge_and_persist(patch: &serde_json::Value, path: &Path) -> Result<CortexConfig, Status> {
284 let mut merged: CortexConfig = Figment::from(Serialized::defaults(config().clone()))
285 .merge(Serialized::defaults(patch))
286 .extract()
287 .map_err(|_| Status::UnprocessableEntity)?;
288 merged.auth = config().auth.clone();
292 if let Err(reason) = validate_config_bounds(&merged) {
296 tracing::warn!(%reason, "rejected a config update that would brick a component");
297 return Err(Status::UnprocessableEntity);
298 }
299 let toml_text =
300 crate::config::to_persisted_toml(&merged).map_err(|_| Status::InternalServerError)?;
301 crate::bootstrap::write_config_atomically(path, &toml_text)
305 .map_err(|_| Status::InternalServerError)?;
306 Ok(merged)
307}
308
309fn validate_config_bounds(c: &CortexConfig) -> Result<(), String> {
314 if c.database.pool_size < 1 {
315 return Err("database.pool_size must be >= 1 (a zero pool blocks every request)".to_string());
316 }
317 let port_ok = |port: usize| (1..=65535).contains(&port);
318 if !port_ok(c.dispatcher.source_port) {
319 return Err(format!(
320 "dispatcher.source_port {} is out of range (1..=65535)",
321 c.dispatcher.source_port
322 ));
323 }
324 if !port_ok(c.dispatcher.result_port) {
325 return Err(format!(
326 "dispatcher.result_port {} is out of range (1..=65535)",
327 c.dispatcher.result_port
328 ));
329 }
330 if c.dispatcher.source_port == c.dispatcher.result_port {
331 return Err(
332 "dispatcher.source_port and result_port must differ (the ventilator and sink can't \
333 share a port)"
334 .to_string(),
335 );
336 }
337 if c.dispatcher.queue_size < 1 {
338 return Err("dispatcher.queue_size must be >= 1 (a zero queue leases no work)".to_string());
339 }
340 if c.dispatcher.max_in_flight < 1 {
341 return Err("dispatcher.max_in_flight must be >= 1".to_string());
342 }
343 if c.dispatcher.max_result_bytes < 1 {
344 return Err(
345 "dispatcher.max_result_bytes must be >= 1 (a zero cap rejects every result)".to_string(),
346 );
347 }
348 if c.jobs.stale_timeout_seconds < 1 {
349 return Err(
350 "jobs.stale_timeout_seconds must be >= 1 (a non-positive timeout reaps every job \
351 instantly)"
352 .to_string(),
353 );
354 }
355 Ok(())
356}
357
358#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
360pub struct RouteInfo {
361 pub method: String,
363 pub uri: String,
365 pub name: Option<String>,
367}
368
369pub struct RouteTable(pub Vec<RouteInfo>);
372impl RouteTable {
373 pub fn snapshot(rocket: &Rocket<Build>) -> RouteTable {
375 RouteTable(
376 rocket
377 .routes()
378 .map(|route| RouteInfo {
379 method: route.method.to_string(),
380 uri: route.uri.to_string(),
381 name: route.name.as_deref().map(str::to_string),
382 })
383 .collect(),
384 )
385 }
386}
387
388#[derive(Debug, Serialize, schemars::JsonSchema)]
392pub struct ApiIndexDto {
393 pub description: &'static str,
395 pub openapi: &'static str,
398 pub docs: &'static str,
400 pub count: usize,
402 pub endpoints: Vec<RouteInfo>,
404}
405
406#[rocket_okapi::openapi(tag = "Meta")]
408#[get("/api")]
409pub fn api_index(routes: &State<RouteTable>) -> Json<ApiIndexDto> {
410 let mut endpoints: Vec<RouteInfo> = routes
411 .0
412 .iter()
413 .filter(|r| r.uri == "/api" || r.uri.starts_with("/api/"))
414 .cloned()
415 .collect();
416 endpoints.sort_by(|a, b| a.uri.cmp(&b.uri).then_with(|| a.method.cmp(&b.method)));
417 Json(ApiIndexDto {
418 description: "CorTeX agent API. Enumerate endpoints below; see `openapi` for the full typed \
419 contract. Most reads are open; mutations require an X-Cortex-Token header.",
420 openapi: "/api/openapi.json",
421 docs: "/api/docs",
422 count: endpoints.len(),
423 endpoints,
424 })
425}
426
427#[rocket_okapi::openapi(tag = "Management")]
434#[get("/api/config")]
435pub fn api_config(_caller: Actor) -> Json<ConfigDto> { Json(ConfigDto::from_config(config())) }
436
437fn port_listening(port: usize) -> bool {
442 use std::net::{TcpStream, ToSocketAddrs};
443 ("127.0.0.1", port as u16)
444 .to_socket_addrs()
445 .ok()
446 .and_then(|mut addrs| addrs.next())
447 .is_some_and(|addr| {
448 TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)).is_ok()
449 })
450}
451
452fn health_report(pool: &DbPool) -> HealthDto {
456 let (reachable, migrations_current, corpora_paths) = match pool.get() {
457 Ok(mut connection) => {
458 use crate::schema::corpora;
459 let reachable = diesel::sql_query("SELECT 1")
460 .execute(&mut *connection)
461 .is_ok();
462 let migrations_current = !crate::migrations::has_pending_migrations(&mut connection);
463 let corpora_paths: Vec<(String, String)> = corpora::table
466 .select((corpora::name, corpora::path))
467 .load(&mut connection)
468 .unwrap_or_default();
469 (reachable, migrations_current, corpora_paths)
470 },
471 Err(_) => (false, false, Vec::new()),
472 };
473 let corpora_checked = corpora_paths
476 .iter()
477 .filter(|(_, path)| !path.is_empty())
478 .count();
479 let unreadable: Vec<UnreadableCorpus> = corpora_paths
480 .into_iter()
481 .filter(|(_, path)| !path.is_empty() && !Path::new(path).is_dir())
482 .map(|(name, path)| UnreadableCorpus { name, path })
483 .collect();
484 let state = pool.state();
486 let status = if reachable && migrations_current {
487 "ok"
488 } else {
489 "degraded"
490 };
491 let mut report = HealthDto {
492 status,
493 database: DbHealth { reachable },
494 migrations: MigrationsHealth {
495 current: migrations_current,
496 },
497 pool: PoolHealth {
498 max: pool.max_size(),
499 connections: state.connections,
500 idle: state.idle_connections,
501 in_use: state.connections.saturating_sub(state.idle_connections),
502 },
503 dispatcher: {
504 let dispatcher = &config().dispatcher;
505 DispatcherHealth {
506 reachable: port_listening(dispatcher.source_port) && port_listening(dispatcher.result_port),
507 source_port: dispatcher.source_port,
508 result_port: dispatcher.result_port,
509 }
510 },
511 storage: StorageHealth {
512 corpora_checked,
513 unreadable,
514 },
515 remediations: Vec::new(),
516 };
517 report.remediations = report.remediations();
519 report
520}
521
522fn liveness_report(pool: &DbPool) -> LivenessDto {
525 let reachable = pool
526 .get()
527 .map(|mut connection| {
528 diesel::sql_query("SELECT 1")
529 .execute(&mut *connection)
530 .is_ok()
531 })
532 .unwrap_or(false);
533 LivenessDto {
534 status: if reachable { "ok" } else { "degraded" },
535 database: DbHealth { reachable },
536 }
537}
538
539#[rocket_okapi::openapi(tag = "Management")]
544#[get("/healthz")]
545pub fn healthz(pool: &State<DbPool>) -> Json<LivenessDto> { Json(liveness_report(pool)) }
546
547#[rocket_okapi::openapi(tag = "Management")]
552#[get("/api/health")]
553pub fn api_health(_caller: Actor, pool: &State<DbPool>) -> Json<HealthDto> {
554 Json(health_report(pool))
555}
556
557#[allow(clippy::result_large_err)] #[get("/health")]
563pub fn health_page(
564 session: Option<AdminSession>,
565 return_to: ReturnTo,
566 pool: &State<DbPool>,
567) -> Result<Template, AdminReject> {
568 require_admin_to(session, &return_to)?;
569 let health = health_report(pool);
570 let global = serde_json::json!({
571 "title": format!("System health — {}", health.status),
572 "description": "CorTeX system health: database, schema migrations, connection pool.",
573 });
574 Ok(Template::render("health", context! { global, health }))
575}
576
577#[allow(clippy::result_large_err)] #[get("/settings?<saved>")]
581pub fn settings(
582 saved: Option<bool>,
583 session: Option<AdminSession>,
584 return_to: ReturnTo,
585) -> Result<Template, AdminReject> {
586 require_admin_to(session, &return_to)?;
587 let global = serde_json::json!({
588 "title": "Configuration",
589 "description": "CorTeX framework configuration",
590 });
591 Ok(Template::render(
595 "settings",
596 context! { global, config: ConfigDto::from_config(config()), saved: saved.unwrap_or(false) },
597 ))
598}
599
600#[rocket_okapi::openapi(tag = "Management")]
606#[put("/api/config", format = "json", data = "<patch>")]
607pub fn put_config(
608 patch: Json<serde_json::Value>,
609 actor: Actor,
610 config_file: &State<ConfigFile>,
611) -> Result<Json<ConfigDto>, Status> {
612 let patch = patch.into_inner();
613 let sections: Vec<&str> = patch
616 .as_object()
617 .map(|o| o.keys().map(String::as_str).collect())
618 .unwrap_or_default();
619 let merged = merge_and_persist(&patch, &config_file.0)?;
620 tracing::info!(actor = %actor.owner, sections = ?sections, "config updated via API");
621 Ok(Json(ConfigDto::from_config(&merged)))
622}
623
624#[allow(clippy::result_large_err)] #[post("/settings", data = "<form>")]
629pub fn post_settings(
630 form: Form<SettingsForm>,
631 session: Option<AdminSession>,
632 config_file: &State<ConfigFile>,
633) -> Result<Redirect, AdminReject> {
634 let _session = require_admin(session)?;
635 let f = form.into_inner();
636 let patch = serde_json::json!({
637 "dispatcher": {
638 "source_port": f.dispatcher_source_port,
639 "result_port": f.dispatcher_result_port,
640 "queue_size": f.dispatcher_queue_size,
641 "message_size": f.dispatcher_message_size,
642 "max_in_flight": f.dispatcher_max_in_flight,
643 "max_result_bytes": f.dispatcher_max_result_bytes,
644 "report_refresh_interval_seconds": f.dispatcher_report_refresh_interval_seconds,
645 },
646 "jobs": { "stale_timeout_seconds": f.jobs_stale_timeout_seconds },
647 "assets": { "template_dir": f.assets_template_dir, "public_dir": f.assets_public_dir },
648 });
649 merge_and_persist(&patch, &config_file.0)?;
650 Ok(Redirect::to("/settings?saved=true"))
651}
652
653#[derive(Debug, Serialize, schemars::JsonSchema)]
655pub struct MaintenanceAckDto {
656 pub job: String,
658 pub poll: String,
660 pub actor: String,
662}
663
664#[rocket_okapi::openapi(tag = "Management")]
669#[post("/api/maintenance/reindex")]
670pub fn reindex(
671 actor: Actor,
672 pool: &State<DbPool>,
673) -> Result<(Status, Json<MaintenanceAckDto>), Status> {
674 let job_uuid = crate::jobs::spawn_reindex(pool.inner().clone(), &actor.owner)
675 .map_err(|_| Status::InternalServerError)?;
676 Ok((
677 Status::Accepted,
678 Json(MaintenanceAckDto {
679 job: job_uuid.to_string(),
680 poll: format!("/api/jobs/{job_uuid}"),
681 actor: actor.owner,
682 }),
683 ))
684}
685
686#[allow(clippy::result_large_err)] #[post("/maintenance/reindex")]
691pub fn reindex_human(
692 session: Option<AdminSession>,
693 pool: &State<DbPool>,
694) -> Result<rocket::response::Redirect, AdminReject> {
695 let session = require_admin(session)?;
696 let uuid = crate::jobs::spawn_reindex(pool.inner().clone(), &session.owner)
697 .map_err(|_| Status::InternalServerError)?;
698 Ok(rocket::response::Redirect::to(format!("/jobs/{uuid}")))
699}
700
701#[rocket_okapi::openapi(tag = "Management")]
707#[post("/api/maintenance/analyze")]
708pub fn analyze(
709 actor: Actor,
710 pool: &State<DbPool>,
711) -> Result<(Status, Json<MaintenanceAckDto>), Status> {
712 let job_uuid = crate::jobs::spawn_analyze(pool.inner().clone(), &actor.owner)
713 .map_err(|_| Status::InternalServerError)?;
714 Ok((
715 Status::Accepted,
716 Json(MaintenanceAckDto {
717 job: job_uuid.to_string(),
718 poll: format!("/api/jobs/{job_uuid}"),
719 actor: actor.owner,
720 }),
721 ))
722}
723
724#[allow(clippy::result_large_err)] #[post("/maintenance/analyze")]
729pub fn analyze_human(
730 session: Option<AdminSession>,
731 pool: &State<DbPool>,
732) -> Result<rocket::response::Redirect, AdminReject> {
733 let session = require_admin(session)?;
734 let uuid = crate::jobs::spawn_analyze(pool.inner().clone(), &session.owner)
735 .map_err(|_| Status::InternalServerError)?;
736 Ok(rocket::response::Redirect::to(format!("/jobs/{uuid}")))
737}
738
739pub fn routes() -> Vec<Route> {
741 routes![
744 health_page,
745 reindex_human,
746 analyze_human,
747 settings,
748 post_settings
749 ]
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 fn healthy() -> HealthDto {
758 HealthDto {
759 status: "ok",
760 database: DbHealth { reachable: true },
761 migrations: MigrationsHealth { current: true },
762 pool: PoolHealth {
763 max: 32,
764 connections: 4,
765 idle: 4,
766 in_use: 0,
767 },
768 dispatcher: DispatcherHealth {
769 reachable: true,
770 source_port: 51695,
771 result_port: 51696,
772 },
773 storage: StorageHealth {
774 corpora_checked: 2,
775 unreadable: Vec::new(),
776 },
777 remediations: Vec::new(),
778 }
779 }
780
781 #[test]
782 fn healthy_report_has_no_remediations() {
783 assert!(
784 healthy().remediations().is_empty(),
785 "an all-clear report needs no actions"
786 );
787 }
788
789 #[test]
790 fn db_down_surfaces_only_the_db_fix() {
791 let mut health = healthy();
792 health.database.reachable = false;
793 health.migrations.current = false; let hints = health.remediations();
795 assert_eq!(hints.len(), 1, "a down DB surfaces only the DB fix");
796 assert!(
797 hints[0].contains("database") && hints[0].contains("DATABASE_URL"),
798 "the DB hint names the URL to check"
799 );
800 }
801
802 #[test]
803 fn pending_migrations_point_at_init() {
804 let mut health = healthy();
805 health.migrations.current = false;
806 assert!(
807 health
808 .remediations()
809 .iter()
810 .any(|h| h.contains("cortex init")),
811 "pending migrations point at cortex init"
812 );
813 }
814
815 #[test]
816 fn pool_exhaustion_is_flagged_with_counts() {
817 let mut health = healthy();
818 health.pool.in_use = health.pool.max; assert!(
820 health
821 .remediations()
822 .iter()
823 .any(|h| h.contains("pool exhausted") && h.contains("32/32")),
824 "an exhausted pool is flagged with its in-use/max counts"
825 );
826 }
827
828 #[test]
829 fn dispatcher_and_storage_warnings_are_actionable() {
830 let mut health = healthy();
831 health.dispatcher.reachable = false;
832 health.storage.unreadable = vec![UnreadableCorpus {
833 name: "arxiv".to_string(),
834 path: "/data/arxiv".to_string(),
835 }];
836 let hints = health.remediations();
837 assert!(
838 hints.iter().any(|h| h.contains("dispatcher not listening")),
839 "an unreachable dispatcher is actionable"
840 );
841 assert!(
842 hints
843 .iter()
844 .any(|h| h.contains("unreadable") && h.contains("arxiv")),
845 "an unreadable corpus path is named in the hint"
846 );
847 }
848}