Skip to main content

systemprompt_api/routes/admin/services/
mod.rs

1//! Admin endpoints reporting and refreshing the services bundle composition.
2//!
3//! `GET /status` answers "what tree is this instance actually serving, and
4//! why" — including the failure text when the instance fell back to a cached
5//! or baked tree, so an instance running yesterday's bundle does not look
6//! healthy. `POST /refresh` re-runs the boot-time resolution against the
7//! configured sources, recomposes, and when the composition changed projects
8//! it into the authz tables and the inventory in-process; `restart=true` is
9//! an opt-in for the static config a running process cannot re-read.
10//!
11//! Two refreshes must not fetch at once, so the router owns a single-flight
12//! lock and the second caller is refused rather than queued behind a
13//! multi-megabyte download.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18mod refresh;
19mod status;
20
21use std::sync::Arc;
22
23use axum::routing::{get, post};
24use axum::{Extension, Router};
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use systemprompt_loader::services_root::{ActiveServicesRoot, ServicesProvenance};
28use systemprompt_models::services::bundle::ServicesBundleState;
29use systemprompt_runtime::AppContext;
30
31pub use refresh::{RefreshQuery, refresh};
32pub use status::build_status;
33
34pub(super) fn router() -> Router<AppContext> {
35    Router::new()
36        .route("/status", get(status::status))
37        .route("/refresh", post(refresh))
38        .layer(Extension(RefreshLock::default()))
39}
40
41/// Single-flight guard around the fetch-verify-compose pipeline.
42///
43/// Held for the whole refresh, so a caller that cannot take it is told the
44/// refresh is already running instead of waiting on the lock and timing the
45/// request out.
46#[derive(Debug, Clone, Default)]
47pub struct RefreshLock(Arc<tokio::sync::Mutex<()>>);
48
49impl RefreshLock {
50    pub fn try_acquire(&self) -> Option<tokio::sync::OwnedMutexGuard<()>> {
51        Arc::clone(&self.0).try_lock_owned().ok()
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct SourceView {
57    pub name: String,
58    pub digest: String,
59    pub version: String,
60    pub content_hash: String,
61    pub fetched_at: DateTime<Utc>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ProvenanceView {
66    pub kind: String,
67
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub composed_hash: Option<String>,
70
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub error: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ServicesStatusResponse {
77    pub active_root: String,
78    pub provenance: ProvenanceView,
79    pub sources: Vec<SourceView>,
80
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub last_reconciled_hash: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct ServicesRefreshResponse {
87    pub changed: bool,
88
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub composed_hash: Option<String>,
91
92    pub sources: Vec<SourceView>,
93    pub reconciled: bool,
94    pub restart_recommended: bool,
95    pub restarting: bool,
96}
97
98pub fn source_views(state: &ServicesBundleState) -> Vec<SourceView> {
99    state
100        .sources
101        .iter()
102        .map(|(name, s)| SourceView {
103            name: name.clone(),
104            digest: s.digest.clone(),
105            version: s.version.clone(),
106            content_hash: s.content_hash.clone(),
107            fetched_at: s.fetched_at,
108        })
109        .collect()
110}
111
112pub fn provenance_view(provenance: &ServicesProvenance) -> ProvenanceView {
113    match provenance {
114        ServicesProvenance::Bundled => ProvenanceView {
115            kind: "bundled".to_owned(),
116            composed_hash: None,
117            error: None,
118        },
119        ServicesProvenance::Fetched { composed_hash, .. } => ProvenanceView {
120            kind: "fetched".to_owned(),
121            composed_hash: Some(composed_hash.clone()),
122            error: None,
123        },
124        ServicesProvenance::LastGood {
125            composed_hash,
126            error,
127        } => ProvenanceView {
128            kind: "last_good".to_owned(),
129            composed_hash: Some(composed_hash.clone()),
130            error: Some(error.clone()),
131        },
132        ServicesProvenance::BundledFallback { error } => ProvenanceView {
133            kind: "bundled".to_owned(),
134            composed_hash: None,
135            error: Some(error.clone()),
136        },
137    }
138}
139
140pub const fn composed_hash_of(active: &ActiveServicesRoot) -> Option<&str> {
141    match &active.provenance {
142        ServicesProvenance::Fetched { composed_hash, .. }
143        | ServicesProvenance::LastGood { composed_hash, .. } => Some(composed_hash.as_str()),
144        ServicesProvenance::Bundled | ServicesProvenance::BundledFallback { .. } => None,
145    }
146}