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, 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) => (
139            StatusCode::OK,
140            Json(SnapshotResponse {
141                format_version: meta.format_version,
142                node_count: meta.node_count as u64,
143                relationship_count: meta.relationship_count as u64,
144                wal_lsn: meta.wal_lsn,
145                path: path.display().to_string(),
146            }),
147        )
148            .into_response(),
149        Err(err) => lora_error_response(err),
150    }
151}
152
153async fn admin_snapshot_load(
154    State(cfg): State<SnapshotAdminConfig>,
155    body: Option<Json<SnapshotRequest>>,
156) -> impl IntoResponse {
157    let req = body.map(|Json(r)| r);
158    let path = resolve_snapshot_path(&cfg, req.as_ref());
159
160    match cfg.admin.load_snapshot(&path) {
161        Ok(meta) => (
162            StatusCode::OK,
163            Json(SnapshotResponse {
164                format_version: meta.format_version,
165                node_count: meta.node_count as u64,
166                relationship_count: meta.relationship_count as u64,
167                wal_lsn: meta.wal_lsn,
168                path: path.display().to_string(),
169            }),
170        )
171            .into_response(),
172        Err(err) => lora_error_response(err),
173    }
174}
175
176fn resolve_checkpoint_path(
177    state: &WalAdminState,
178    req: Option<&SnapshotRequest>,
179) -> Result<PathBuf, &'static str> {
180    match req.and_then(|r| r.path.as_deref()) {
181        Some(p) if !p.trim().is_empty() => Ok(PathBuf::from(p)),
182        _ => state
183            .default_checkpoint_path
184            .clone()
185            .ok_or("no checkpoint path: pass `path` in the request body or start the server with --snapshot-path"),
186    }
187}
188
189async fn admin_checkpoint(
190    State(state): State<WalAdminState>,
191    body: Option<Json<SnapshotRequest>>,
192) -> impl IntoResponse {
193    let req = body.map(|Json(r)| r);
194    let path = match resolve_checkpoint_path(&state, req.as_ref()) {
195        Ok(p) => p,
196        Err(msg) => {
197            return (
198                StatusCode::BAD_REQUEST,
199                Json(ErrorResponse::from_parts(LoraErrorCode::Config, msg)),
200            )
201                .into_response()
202        }
203    };
204
205    match state.wal.checkpoint(&path) {
206        Ok(meta) => (
207            StatusCode::OK,
208            Json(SnapshotResponse {
209                format_version: meta.format_version,
210                node_count: meta.node_count as u64,
211                relationship_count: meta.relationship_count as u64,
212                wal_lsn: meta.wal_lsn,
213                path: path.display().to_string(),
214            }),
215        )
216            .into_response(),
217        Err(err) => lora_error_response(err),
218    }
219}
220
221async fn admin_wal_status(State(state): State<WalAdminState>) -> impl IntoResponse {
222    match state.wal.wal_status() {
223        Ok(s) => (
224            StatusCode::OK,
225            Json(WalStatusResponse {
226                durable_lsn: s.durable_lsn,
227                next_lsn: s.next_lsn,
228                active_segment_id: s.active_segment_id,
229                oldest_segment_id: s.oldest_segment_id,
230                bg_failure: s.bg_failure,
231            }),
232        )
233            .into_response(),
234        Err(err) => lora_error_response(err),
235    }
236}
237
238async fn admin_wal_truncate(
239    State(state): State<WalAdminState>,
240    body: Option<Json<WalTruncateRequest>>,
241) -> impl IntoResponse {
242    // No body / no fence => truncate up to the WAL's current durable
243    // LSN. That's the natural "drop everything safe to drop" default.
244    let fence = match body.and_then(|Json(r)| r.fence_lsn) {
245        Some(lsn) => lsn,
246        None => match state.wal.wal_status() {
247            Ok(s) => s.durable_lsn,
248            Err(err) => return lora_error_response(err),
249        },
250    };
251
252    match state.wal.wal_truncate(fence) {
253        Ok(()) => StatusCode::NO_CONTENT.into_response(),
254        Err(err) => lora_error_response(err),
255    }
256}