alopex_server/http/
admin_api.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use axum::extract::Extension;
5use axum::http::StatusCode;
6use axum::response::IntoResponse;
7use axum::Json;
8use serde::{Deserialize, Serialize};
9
10use crate::auth::AuthMode;
11use crate::server::ServerState;
12
13#[derive(Serialize)]
14struct AdminCapabilitiesResponse {
15    scope: &'static str,
16    allowed_actions: Vec<&'static str>,
17}
18
19#[derive(Serialize)]
20struct AdminStatusResponse {
21    version: Option<String>,
22    uptime_secs: Option<u64>,
23    connections: Option<u64>,
24    queries_per_second: Option<f64>,
25}
26
27#[derive(Serialize)]
28struct AdminMetricsResponse {
29    qps: Option<f64>,
30    avg_latency_ms: Option<f64>,
31    p99_latency_ms: Option<f64>,
32    memory_usage_mb: Option<u64>,
33    active_connections: Option<u64>,
34}
35
36#[derive(Serialize)]
37struct AdminHealthResponse {
38    status: &'static str,
39    message: &'static str,
40}
41
42#[derive(Deserialize)]
43pub struct AdminLifecycleRequest {
44    action: String,
45}
46
47#[derive(Serialize)]
48struct AdminLifecycleResponse {
49    status: &'static str,
50    message: String,
51}
52
53#[derive(Serialize)]
54struct AdminCompactionResponse {
55    success: bool,
56    message: String,
57}
58
59pub async fn capabilities(Extension(state): Extension<Arc<ServerState>>) -> impl IntoResponse {
60    let (scope, allowed_actions) = capabilities_for_auth(&state.auth);
61    Json(AdminCapabilitiesResponse {
62        scope,
63        allowed_actions,
64    })
65}
66
67pub async fn status(Extension(state): Extension<Arc<ServerState>>) -> impl IntoResponse {
68    let uptime = state.start_time.elapsed().as_secs();
69    Json(AdminStatusResponse {
70        version: Some(env!("CARGO_PKG_VERSION").to_string()),
71        uptime_secs: Some(uptime),
72        connections: None,
73        queries_per_second: None,
74    })
75}
76
77pub async fn metrics(Extension(_state): Extension<Arc<ServerState>>) -> impl IntoResponse {
78    Json(AdminMetricsResponse {
79        qps: None,
80        avg_latency_ms: None,
81        p99_latency_ms: None,
82        memory_usage_mb: None,
83        active_connections: None,
84    })
85}
86
87pub async fn health() -> impl IntoResponse {
88    Json(AdminHealthResponse {
89        status: "ok",
90        message: "ready",
91    })
92}
93
94pub async fn compaction() -> impl IntoResponse {
95    Json(AdminCompactionResponse {
96        success: false,
97        message: "Compaction is not available on this server build.".to_string(),
98    })
99}
100
101pub async fn lifecycle(
102    Extension(state): Extension<Arc<ServerState>>,
103    Json(request): Json<AdminLifecycleRequest>,
104) -> impl IntoResponse {
105    let data_dir = state.config.data_dir.clone();
106    let action = request.action;
107    let result = tokio::task::spawn_blocking(move || {
108        perform_lifecycle_action(action.as_str(), Path::new(&data_dir))
109    })
110    .await
111    .map_err(|err| err.to_string())
112    .and_then(|res| res.map_err(|err| err.to_string()));
113
114    match result {
115        Ok(message) => (
116            StatusCode::OK,
117            Json(AdminLifecycleResponse {
118                status: "OK",
119                message,
120            }),
121        )
122            .into_response(),
123        Err(err) => (
124            StatusCode::BAD_REQUEST,
125            Json(AdminLifecycleResponse {
126                status: "Error",
127                message: err,
128            }),
129        )
130            .into_response(),
131    }
132}
133
134fn capabilities_for_auth(auth: &crate::auth::AuthMiddleware) -> (&'static str, Vec<&'static str>) {
135    match auth.mode() {
136        AuthMode::None => ("full", Vec::new()),
137        AuthMode::Dev { .. } => ("restricted", all_actions()),
138    }
139}
140
141fn all_actions() -> Vec<&'static str> {
142    vec![
143        "read", "create", "update", "delete", "archive", "restore", "backup", "export",
144    ]
145}
146
147fn perform_lifecycle_action(action: &str, data_dir: &Path) -> Result<String, String> {
148    if !data_dir.exists() {
149        return Err(format!(
150            "Data directory does not exist: {}",
151            data_dir.display()
152        ));
153    }
154    if !data_dir.is_dir() {
155        return Err(format!(
156            "Data directory is not a directory: {}",
157            data_dir.display()
158        ));
159    }
160
161    let lifecycle_root = data_dir.join(".lifecycle");
162    std::fs::create_dir_all(&lifecycle_root).map_err(|err| err.to_string())?;
163
164    match action {
165        "archive" => {
166            let dest = lifecycle_root.join("archive").join(timestamp_dir());
167            copy_data_dir(data_dir, &dest)?;
168            write_latest_marker(&lifecycle_root.join("archive"), &dest)?;
169            Ok(format!("Archived data to {}", dest.display()))
170        }
171        "restore" => {
172            let archive_root = lifecycle_root.join("archive");
173            let latest = read_latest_marker(&archive_root)?;
174            let backup_dir = lifecycle_root.join("restore-backup").join(timestamp_dir());
175            copy_data_dir(data_dir, &backup_dir)?;
176            clear_data_dir(data_dir)?;
177            copy_data_dir(&latest, data_dir)?;
178            Ok(format!(
179                "Restored data from {} (backup at {})",
180                latest.display(),
181                backup_dir.display()
182            ))
183        }
184        "backup" => {
185            let dest = lifecycle_root.join("backup").join(timestamp_dir());
186            copy_data_dir(data_dir, &dest)?;
187            write_latest_marker(&lifecycle_root.join("backup"), &dest)?;
188            Ok(format!("Backup created at {}", dest.display()))
189        }
190        "export" => {
191            let dest = lifecycle_root.join("export").join(timestamp_dir());
192            copy_data_dir(data_dir, &dest)?;
193            write_latest_marker(&lifecycle_root.join("export"), &dest)?;
194            Ok(format!("Exported data to {}", dest.display()))
195        }
196        _ => Err("Unknown lifecycle action.".to_string()),
197    }
198}
199
200fn timestamp_dir() -> String {
201    let seconds = std::time::SystemTime::now()
202        .duration_since(std::time::UNIX_EPOCH)
203        .unwrap_or_default()
204        .as_secs();
205    format!("ts-{seconds}")
206}
207
208fn copy_data_dir(src: &Path, dest: &Path) -> Result<(), String> {
209    std::fs::create_dir_all(dest).map_err(|err| err.to_string())?;
210    copy_dir_filtered(src, dest)
211}
212
213fn copy_dir_filtered(src: &Path, dest: &Path) -> Result<(), String> {
214    for entry in std::fs::read_dir(src).map_err(|err| err.to_string())? {
215        let entry = entry.map_err(|err| err.to_string())?;
216        let file_type = entry.file_type().map_err(|err| err.to_string())?;
217        let name = entry.file_name();
218        if name == ".lifecycle" {
219            continue;
220        }
221        let dest_path = dest.join(name);
222        if file_type.is_dir() {
223            copy_data_dir(&entry.path(), &dest_path)?;
224        } else {
225            std::fs::copy(entry.path(), &dest_path).map_err(|err| err.to_string())?;
226        }
227    }
228    Ok(())
229}
230
231fn clear_data_dir(dir: &Path) -> Result<(), String> {
232    for entry in std::fs::read_dir(dir).map_err(|err| err.to_string())? {
233        let entry = entry.map_err(|err| err.to_string())?;
234        let name = entry.file_name();
235        if name == ".lifecycle" {
236            continue;
237        }
238        let path = entry.path();
239        if path.is_dir() {
240            std::fs::remove_dir_all(&path).map_err(|err| err.to_string())?;
241        } else {
242            std::fs::remove_file(&path).map_err(|err| err.to_string())?;
243        }
244    }
245    Ok(())
246}
247
248fn write_latest_marker(root: &Path, dest: &Path) -> Result<(), String> {
249    let marker = root.join("latest");
250    std::fs::create_dir_all(root).map_err(|err| err.to_string())?;
251    std::fs::write(&marker, dest.to_string_lossy().as_bytes()).map_err(|err| err.to_string())?;
252    Ok(())
253}
254
255fn read_latest_marker(root: &Path) -> Result<std::path::PathBuf, String> {
256    let marker = root.join("latest");
257    let contents = std::fs::read_to_string(&marker).map_err(|err| err.to_string())?;
258    let path = contents.trim();
259    if path.is_empty() {
260        return Err("Lifecycle archive marker is empty.".to_string());
261    }
262    Ok(std::path::PathBuf::from(path))
263}