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 a process-wide single-flight
12//! lock guards the pipeline and the second caller is refused rather than
13//! queued behind a multi-megabyte download. [`ServicesRefresh`] is the
14//! in-process handle extensions run the same pipeline through.
15//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19mod refresh;
20mod status;
21
22use std::sync::Arc;
23
24use axum::routing::{get, post};
25use axum::{Extension, Router};
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28use systemprompt_loader::services_root::{ActiveServicesRoot, ServicesProvenance};
29use systemprompt_models::services::bundle::ServicesBundleState;
30use systemprompt_runtime::AppContext;
31
32pub use refresh::{RefreshQuery, ServicesRefresh, process_refresh_lock, refresh};
33pub use status::build_status;
34
35pub(super) fn router() -> Router<AppContext> {
36    Router::new()
37        .route("/status", get(status::status))
38        .route("/refresh", post(refresh))
39        .layer(Extension(process_refresh_lock()))
40}
41
42/// Single-flight guard around the fetch-verify-compose pipeline.
43///
44/// Held for the whole refresh, so a caller that cannot take it is told the
45/// refresh is already running instead of waiting on the lock and timing the
46/// request out.
47#[derive(Debug, Clone, Default)]
48pub struct RefreshLock(Arc<tokio::sync::Mutex<()>>);
49
50impl RefreshLock {
51    pub fn try_acquire(&self) -> Option<tokio::sync::OwnedMutexGuard<()>> {
52        Arc::clone(&self.0).try_lock_owned().ok()
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct SourceView {
58    pub name: String,
59    pub digest: String,
60    pub version: String,
61    pub content_hash: String,
62    pub fetched_at: DateTime<Utc>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ProvenanceView {
67    pub kind: String,
68
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub composed_hash: Option<String>,
71
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub error: Option<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct ServicesStatusResponse {
78    pub active_root: String,
79    pub provenance: ProvenanceView,
80    pub sources: Vec<SourceView>,
81
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub last_reconciled_hash: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct ServicesRefreshResponse {
88    pub changed: bool,
89
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub composed_hash: Option<String>,
92
93    pub sources: Vec<SourceView>,
94    pub reconciled: bool,
95    pub restart_recommended: bool,
96    pub restarting: bool,
97}
98
99pub fn source_views(state: &ServicesBundleState) -> Vec<SourceView> {
100    state
101        .sources
102        .iter()
103        .map(|(name, s)| SourceView {
104            name: name.clone(),
105            digest: s.digest.clone(),
106            version: s.version.clone(),
107            content_hash: s.content_hash.clone(),
108            fetched_at: s.fetched_at,
109        })
110        .collect()
111}
112
113pub fn provenance_view(provenance: &ServicesProvenance) -> ProvenanceView {
114    match provenance {
115        ServicesProvenance::Bundled => ProvenanceView {
116            kind: "bundled".to_owned(),
117            composed_hash: None,
118            error: None,
119        },
120        ServicesProvenance::Fetched { composed_hash, .. } => ProvenanceView {
121            kind: "fetched".to_owned(),
122            composed_hash: Some(composed_hash.clone()),
123            error: None,
124        },
125        ServicesProvenance::LastGood {
126            composed_hash,
127            error,
128        } => ProvenanceView {
129            kind: "last_good".to_owned(),
130            composed_hash: Some(composed_hash.clone()),
131            error: Some(error.clone()),
132        },
133        ServicesProvenance::BundledFallback { error } => ProvenanceView {
134            kind: "bundled".to_owned(),
135            composed_hash: None,
136            error: Some(error.clone()),
137        },
138    }
139}
140
141pub const fn composed_hash_of(active: &ActiveServicesRoot) -> Option<&str> {
142    match &active.provenance {
143        ServicesProvenance::Fetched { composed_hash, .. }
144        | ServicesProvenance::LastGood { composed_hash, .. } => Some(composed_hash.as_str()),
145        ServicesProvenance::Bundled | ServicesProvenance::BundledFallback { .. } => None,
146    }
147}