Skip to main content

lora_server/app/
admin.rs

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