Skip to main content

lora_server/app/
admin.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Json, Router};
5use lora_database::{LoraErrorCode, SnapshotAdmin, SnapshotMeta, WalAdmin};
6
7use super::errors::{lora_error_response, ErrorResponse};
8use super::types::{SnapshotRequest, SnapshotResponse, WalStatusResponse, WalTruncateRequest};
9
10/// Snapshot admin surface. Mounted as a unit so that
11/// `/admin/snapshot/{save,load}` always have a configured default
12/// path: an operator who set `--snapshot-path` is the one paying the
13/// cost of the route's existence, and they reasonably expect the
14/// path to be resolved automatically when no `path` field is sent in
15/// the request body.
16#[derive(Clone)]
17pub struct SnapshotAdminConfig {
18    pub path: PathBuf,
19    pub admin: Arc<dyn SnapshotAdmin>,
20}
21
22/// Configuration for the admin surface. Snapshot and WAL admin are
23/// independent: each set of routes mounts only when its corresponding
24/// field is `Some`.
25///
26/// - `snapshot.is_some()` mounts `POST /admin/snapshot/save` and
27///   `POST /admin/snapshot/load` against the configured path
28///   (the body's optional `path` field overrides per request).
29/// - `wal.is_some()` mounts `POST /admin/wal/status` and
30///   `POST /admin/wal/truncate` unconditionally, plus
31///   `POST /admin/checkpoint` (which uses `snapshot.path` as a default
32///   when present, and otherwise requires `path` in the request body).
33///
34/// The endpoints are intentionally opt-in: exposing them without
35/// authentication on a network-reachable interface is a footgun, so
36/// the caller must explicitly construct an `AdminConfig` and pass it
37/// to the server — there is no implicit default path.
38#[derive(Clone, Default)]
39pub struct AdminConfig {
40    /// Snapshot save/load admin. `None` to disable
41    /// `/admin/snapshot/{save,load}`.
42    pub snapshot: Option<SnapshotAdminConfig>,
43    /// WAL admin. `None` to disable `/admin/wal/*` and
44    /// `/admin/checkpoint`.
45    pub wal: Option<Arc<dyn WalAdmin>>,
46}
47
48impl AdminConfig {
49    /// Construct a snapshot-only admin config (no WAL endpoints).
50    pub fn snapshot_only(snapshot_path: PathBuf, admin: Arc<dyn SnapshotAdmin>) -> Self {
51        Self {
52            snapshot: Some(SnapshotAdminConfig {
53                path: snapshot_path,
54                admin,
55            }),
56            wal: None,
57        }
58    }
59
60    /// Construct a WAL-only admin config (no snapshot endpoints). The
61    /// `/admin/checkpoint` route still mounts but every call needs a
62    /// `path` in the request body since there is no configured
63    /// default.
64    pub fn wal_only(wal: Arc<dyn WalAdmin>) -> Self {
65        Self {
66            snapshot: None,
67            wal: Some(wal),
68        }
69    }
70
71    /// True when neither admin surface is configured. The router
72    /// merge then becomes a no-op and the admin routes don't exist.
73    pub fn is_empty(&self) -> bool {
74        self.snapshot.is_none() && self.wal.is_none()
75    }
76}
77
78pub(crate) fn build_admin_router(cfg: AdminConfig) -> Router {
79    let mut router = Router::new();
80
81    if let Some(snap) = cfg.snapshot.clone() {
82        let snapshot_router: Router = Router::new()
83            .route("/admin/snapshot/save", post(admin_snapshot_save))
84            .route("/admin/snapshot/load", post(admin_snapshot_load))
85            .with_state(snap);
86        router = router.merge(snapshot_router);
87    }
88
89    if let Some(wal) = cfg.wal.clone() {
90        let wal_state = WalAdminState {
91            // Reuse the snapshot path as the default checkpoint
92            // target when present so a body-less
93            // `POST /admin/checkpoint` writes to the same file the
94            // snapshot endpoints use. When no snapshot path is
95            // configured, the handler requires `path` in the body.
96            default_checkpoint_path: cfg.snapshot.as_ref().map(|s| s.path.clone()),
97            wal,
98        };
99        let wal_router: Router = Router::new()
100            .route("/admin/checkpoint", post(admin_checkpoint))
101            .route("/admin/wal/status", post(admin_wal_status))
102            .route("/admin/wal/truncate", post(admin_wal_truncate))
103            .with_state(wal_state);
104        router = router.merge(wal_router);
105    }
106
107    router
108}
109
110/// State plumbed into the WAL admin handlers.
111#[derive(Clone)]
112struct WalAdminState {
113    /// Default target for `POST /admin/checkpoint` when the body
114    /// omits `path`. `None` when the operator did not pass
115    /// `--snapshot-path`; in that case the handler returns 400 with
116    /// a hint.
117    default_checkpoint_path: Option<PathBuf>,
118    wal: Arc<dyn WalAdmin>,
119}
120
121/// Extract the target path for a snapshot operation: the request-body
122/// override if present, else the configured default.
123fn resolve_snapshot_path(cfg: &SnapshotAdminConfig, req: Option<&SnapshotRequest>) -> PathBuf {
124    match req.and_then(|r| r.path.as_deref()) {
125        Some(p) if !p.trim().is_empty() => PathBuf::from(p),
126        _ => cfg.path.clone(),
127    }
128}
129
130async fn admin_snapshot_save(
131    State(cfg): State<SnapshotAdminConfig>,
132    body: Option<Json<SnapshotRequest>>,
133) -> impl IntoResponse {
134    let req = body.map(|Json(r)| r);
135    let path = resolve_snapshot_path(&cfg, req.as_ref());
136
137    match cfg.admin.save_snapshot(&path) {
138        Ok(meta) => snapshot_response(meta, path),
139        Err(err) => lora_error_response(err),
140    }
141}
142
143async fn admin_snapshot_load(
144    State(cfg): State<SnapshotAdminConfig>,
145    body: Option<Json<SnapshotRequest>>,
146) -> impl IntoResponse {
147    let req = body.map(|Json(r)| r);
148    let path = resolve_snapshot_path(&cfg, req.as_ref());
149
150    match cfg.admin.load_snapshot(&path) {
151        Ok(meta) => snapshot_response(meta, path),
152        Err(err) => lora_error_response(err),
153    }
154}
155
156fn resolve_checkpoint_path(
157    state: &WalAdminState,
158    req: Option<&SnapshotRequest>,
159) -> Result<PathBuf, &'static str> {
160    match req.and_then(|r| r.path.as_deref()) {
161        Some(p) if !p.trim().is_empty() => Ok(PathBuf::from(p)),
162        _ => state
163            .default_checkpoint_path
164            .clone()
165            .ok_or("no checkpoint path: pass `path` in the request body or start the server with --snapshot-path"),
166    }
167}
168
169async fn admin_checkpoint(
170    State(state): State<WalAdminState>,
171    body: Option<Json<SnapshotRequest>>,
172) -> impl IntoResponse {
173    let req = body.map(|Json(r)| r);
174    let path = match resolve_checkpoint_path(&state, req.as_ref()) {
175        Ok(p) => p,
176        Err(msg) => {
177            return (
178                StatusCode::BAD_REQUEST,
179                Json(ErrorResponse::from_parts(LoraErrorCode::Config, msg)),
180            )
181                .into_response()
182        }
183    };
184
185    match state.wal.checkpoint(&path) {
186        Ok(meta) => snapshot_response(meta, path),
187        Err(err) => lora_error_response(err),
188    }
189}
190
191fn snapshot_response(meta: SnapshotMeta, path: PathBuf) -> axum::response::Response {
192    (
193        StatusCode::OK,
194        Json(SnapshotResponse {
195            format_version: meta.format_version,
196            node_count: meta.node_count as u64,
197            relationship_count: meta.relationship_count as u64,
198            wal_lsn: meta.wal_lsn,
199            path: path.display().to_string(),
200        }),
201    )
202        .into_response()
203}
204
205async fn admin_wal_status(State(state): State<WalAdminState>) -> impl IntoResponse {
206    match state.wal.wal_status() {
207        Ok(s) => (
208            StatusCode::OK,
209            Json(WalStatusResponse {
210                durable_lsn: s.durable_lsn,
211                next_lsn: s.next_lsn,
212                active_segment_id: s.active_segment_id,
213                oldest_segment_id: s.oldest_segment_id,
214                bg_failure: s.bg_failure,
215            }),
216        )
217            .into_response(),
218        Err(err) => lora_error_response(err),
219    }
220}
221
222async fn admin_wal_truncate(
223    State(state): State<WalAdminState>,
224    body: Option<Json<WalTruncateRequest>>,
225) -> impl IntoResponse {
226    // No body / no fence => truncate up to the WAL's current durable
227    // LSN. That's the natural "drop everything safe to drop" default.
228    let fence = match body.and_then(|Json(r)| r.fence_lsn) {
229        Some(lsn) => lsn,
230        None => match state.wal.wal_status() {
231            Ok(s) => s.durable_lsn,
232            Err(err) => return lora_error_response(err),
233        },
234    };
235
236    match state.wal.wal_truncate(fence) {
237        Ok(()) => StatusCode::NO_CONTENT.into_response(),
238        Err(err) => lora_error_response(err),
239    }
240}