1use std::path::Path;
2use std::sync::Arc;
3
4use alopex_cluster::{
5 bootstrap_cluster_control, ClusterBootstrapConfig, ClusterBootstrapMode,
6 ClusterBootstrapOutcome, ClusterMode, ClusterStatusSnapshot, UpgradeOperation,
7};
8use alopex_core::kv::any::AnyKV;
9use axum::extract::{Extension, Path as AxumPath};
10use axum::http::StatusCode;
11use axum::response::{IntoResponse, Response};
12use axum::Json;
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::auth::AuthMode;
17use crate::http::{error_response, RequestContext};
18use crate::metrics::ClusterMetricsSurface;
19use crate::ops::backup::{copy_dir_filtered, export_snapshot, BackupHandle};
20use crate::ops::restore::{RestoreHandle, RestoreSource};
21use crate::ops::state::{OperationState, RestoreMetadata};
22use crate::ops::status::StatusReporter;
23use crate::ops::status::StatusView;
24use crate::server::ServerState;
25
26#[derive(Serialize)]
27struct AdminCapabilitiesResponse {
28 scope: &'static str,
29 allowed_actions: Vec<&'static str>,
30 unsupported_actions: Vec<&'static str>,
31}
32
33#[derive(Serialize)]
34struct AdminStatusResponse {
35 version: Option<String>,
36 uptime_secs: Option<u64>,
37 connections: Option<u64>,
38 queries_per_second: Option<f64>,
39 cluster: ClusterStatusSnapshot,
40 cluster_control: ClusterControlAvailability,
41 #[serde(flatten)]
42 status: StatusView,
43}
44
45#[derive(Serialize)]
46struct AdminMetricsResponse {
47 qps: Option<f64>,
48 avg_latency_ms: Option<f64>,
49 p99_latency_ms: Option<f64>,
50 memory_usage_mb: Option<u64>,
51 active_connections: Option<u64>,
52 cluster: ClusterStatusSnapshot,
53 cluster_metrics: ClusterMetricsSurface,
54}
55
56#[derive(Serialize)]
57struct AdminHealthResponse {
58 status: &'static str,
59 message: &'static str,
60 degraded: bool,
61 cluster: ClusterStatusSnapshot,
62}
63
64#[derive(Serialize)]
65struct AdminClusterOperationResponse {
66 action: &'static str,
67 cluster: ClusterStatusSnapshot,
68}
69
70#[derive(Debug, Clone, Serialize)]
74pub struct ClusterControlAvailability {
75 pub available: bool,
76 pub mode: ClusterMode,
77 pub reason: &'static str,
78 pub missing_prerequisites: Vec<alopex_cluster::ClusterCapabilityPrerequisite>,
79}
80
81#[derive(Serialize)]
82struct AdminClusterMetadataResponse {
83 cluster: ClusterStatusSnapshot,
84 control: ClusterControlAvailability,
85 metadata_state_version: Option<u64>,
86 schema_rollout: Option<serde_json::Value>,
87 upgrade: Option<UpgradeOperation>,
88}
89
90#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
93#[serde(rename_all = "snake_case")]
94pub enum AdminClusterManagementOperation {
95 MetadataShow,
96 MembersList,
97 MembersReplace,
98 RangesList,
99 RangesRegister,
100 RangesUpdate,
101 RangesRetire,
102 PlacementGet,
103 PlacementSet,
104 PlacementReplace,
105 ReadPolicyGet,
106 ReadPolicySet,
107 SchemaOwnerGet,
108 SchemaOwnerSet,
109 SchemaRolloutStart,
110 SchemaRolloutStatus,
111 RecoveryStatus,
112 RecoveryRestore,
113 UpgradeStatus,
114 UpgradeStart,
115}
116
117impl AdminClusterManagementOperation {
118 fn is_mutation(self) -> bool {
119 !matches!(
120 self,
121 Self::MetadataShow
122 | Self::MembersList
123 | Self::RangesList
124 | Self::PlacementGet
125 | Self::ReadPolicyGet
126 | Self::SchemaOwnerGet
127 | Self::SchemaRolloutStatus
128 | Self::RecoveryStatus
129 | Self::UpgradeStatus
130 )
131 }
132}
133
134#[derive(Debug, Deserialize)]
135pub struct AdminClusterManagementRequest {
136 pub request_id: String,
137 pub operation: AdminClusterManagementOperation,
138 #[serde(default)]
139 pub expected_version: Option<u64>,
140 #[serde(default)]
143 pub target: Option<serde_json::Value>,
144 #[serde(default)]
145 pub confirmed: bool,
146}
147
148#[derive(Serialize)]
149struct AdminClusterManagementResponse {
150 operation_id: String,
151 operation: AdminClusterManagementOperation,
152 outcome_class: &'static str,
153 reason: &'static str,
154 state_version: Option<u64>,
155 control: ClusterControlAvailability,
156 actor: Option<String>,
157}
158
159#[derive(Deserialize)]
160pub struct AdminLifecycleRequest {
161 action: String,
162}
163
164#[derive(Deserialize)]
165pub struct AdminRestoreRequest {
166 #[serde(default)]
167 source: Option<String>,
168}
169
170#[derive(Serialize)]
171struct AdminLifecycleResponse {
172 status: &'static str,
173 message: String,
174}
175
176#[derive(Serialize)]
177struct AdminExportResponse {
178 status: &'static str,
179 location: String,
180}
181
182#[derive(Serialize)]
183struct AdminBackupResponse {
184 handle: String,
185 location: String,
186 state: OperationState,
187}
188
189#[derive(Serialize)]
190struct AdminRestoreResponse {
191 handle: String,
192 state: OperationState,
193 metadata: Option<RestoreMetadata>,
194}
195
196pub async fn capabilities(Extension(state): Extension<Arc<ServerState>>) -> impl IntoResponse {
197 let (scope, allowed_actions, unsupported_actions) = capabilities_for_auth(&state.auth);
198 Json(AdminCapabilitiesResponse {
199 scope,
200 allowed_actions,
201 unsupported_actions,
202 })
203}
204
205pub async fn status(
206 Extension(state): Extension<Arc<ServerState>>,
207 Extension(ctx): Extension<RequestContext>,
208) -> Response {
209 let uptime = state.start_time.elapsed().as_secs();
210 let reporter = StatusReporter::new(state.lifecycle_state.clone(), state.recovery_info.clone());
211 let status = reporter.status_view();
212 let cluster = match state.cluster_status_snapshot() {
213 Ok(snapshot) => snapshot,
214 Err(err) => return error_response(err, &ctx),
215 };
216 let cluster_control = match cluster_control_availability(&cluster) {
217 Ok(control) => control,
218 Err(err) => return error_response(err, &ctx),
219 };
220 state.metrics.record_cluster_status(&cluster);
221 Json(AdminStatusResponse {
222 version: Some(env!("CARGO_PKG_VERSION").to_string()),
223 uptime_secs: Some(uptime),
224 connections: None,
225 queries_per_second: None,
226 cluster,
227 cluster_control,
228 status,
229 })
230 .into_response()
231}
232
233pub async fn cluster_metadata(
234 Extension(state): Extension<Arc<ServerState>>,
235 Extension(ctx): Extension<RequestContext>,
236) -> Response {
237 let cluster = match state.cluster_status_snapshot() {
238 Ok(snapshot) => snapshot,
239 Err(err) => return error_response(err, &ctx),
240 };
241 let control = match cluster_control_availability(&cluster) {
242 Ok(control) => control,
243 Err(err) => return error_response(err, &ctx),
244 };
245 let upgrade = state.upgrade_coordinator.status().ok();
246 Json(AdminClusterMetadataResponse {
247 cluster,
248 control,
249 metadata_state_version: None,
250 schema_rollout: None,
251 upgrade,
252 })
253 .into_response()
254}
255
256pub async fn cluster_management(
257 Extension(state): Extension<Arc<ServerState>>,
258 Extension(ctx): Extension<RequestContext>,
259 Json(request): Json<AdminClusterManagementRequest>,
260) -> Response {
261 let cluster = match state.cluster_status_snapshot() {
262 Ok(snapshot) => snapshot,
263 Err(err) => return error_response(err, &ctx),
264 };
265 let control = match cluster_control_availability(&cluster) {
266 Ok(control) => control,
267 Err(err) => return error_response(err, &ctx),
268 };
269 let (outcome_class, reason) = if request.operation.is_mutation() && !request.confirmed {
270 ("terminal_failure", "confirmation_required")
271 } else if !control.available {
272 ("terminal_failure", "cluster_capability_unavailable")
273 } else {
274 ("pending", "metadata_consensus_adapter_not_attached")
278 };
279 Json(AdminClusterManagementResponse {
280 operation_id: request.request_id,
281 operation: request.operation,
282 outcome_class,
283 reason,
284 state_version: None,
285 control,
286 actor: ctx.actor,
287 })
288 .into_response()
289}
290
291pub async fn metrics(
292 Extension(state): Extension<Arc<ServerState>>,
293 Extension(ctx): Extension<RequestContext>,
294) -> Response {
295 let cluster = match state.cluster_status_snapshot() {
296 Ok(snapshot) => snapshot,
297 Err(err) => return error_response(err, &ctx),
298 };
299 state.metrics.record_cluster_status(&cluster);
300 Json(AdminMetricsResponse {
301 qps: None,
302 avg_latency_ms: None,
303 p99_latency_ms: None,
304 memory_usage_mb: None,
305 active_connections: None,
306 cluster_metrics: ClusterMetricsSurface::from(&cluster),
307 cluster,
308 })
309 .into_response()
310}
311
312pub async fn health(
313 Extension(state): Extension<Arc<ServerState>>,
314 Extension(ctx): Extension<RequestContext>,
315) -> Response {
316 let cluster = match state.cluster_status_snapshot() {
317 Ok(snapshot) => snapshot,
318 Err(err) => return error_response(err, &ctx),
319 };
320 state.metrics.record_cluster_status(&cluster);
321 let (status, message) = if cluster.degraded {
322 ("degraded", "cluster status degraded")
323 } else {
324 ("ok", "ready")
325 };
326 Json(AdminHealthResponse {
327 status,
328 message,
329 degraded: cluster.degraded,
330 cluster,
331 })
332 .into_response()
333}
334
335pub async fn cluster_join(
336 Extension(state): Extension<Arc<ServerState>>,
337 Extension(ctx): Extension<RequestContext>,
338) -> Response {
339 cluster_operation_response(&state, &ctx, "join")
340}
341
342pub async fn cluster_leave(
343 Extension(state): Extension<Arc<ServerState>>,
344 Extension(ctx): Extension<RequestContext>,
345) -> Response {
346 cluster_operation_response(&state, &ctx, "leave")
347}
348
349pub fn cluster_control_availability(
352 cluster: &ClusterStatusSnapshot,
353) -> crate::error::Result<ClusterControlAvailability> {
354 if cluster.mode == ClusterMode::SingleNode {
355 return Ok(ClusterControlAvailability {
356 available: false,
357 mode: cluster.mode,
358 reason: "single_node_mode",
359 missing_prerequisites: Vec::new(),
360 });
361 }
362 let outcome = bootstrap_cluster_control(&ClusterBootstrapConfig::compiled_chirps(
363 ClusterBootstrapMode::ClusterAware,
364 ));
365 match outcome {
366 ClusterBootstrapOutcome::ReadyForClusterControl => Ok(ClusterControlAvailability {
367 available: true,
368 mode: cluster.mode,
369 reason: "ready",
370 missing_prerequisites: Vec::new(),
371 }),
372 ClusterBootstrapOutcome::CapabilityUnavailable {
373 missing_prerequisites,
374 } => Ok(ClusterControlAvailability {
375 available: false,
376 mode: cluster.mode,
377 reason: "cluster_capability_unavailable",
378 missing_prerequisites,
379 }),
380 ClusterBootstrapOutcome::SingleNode => unreachable!("cluster-aware input was supplied"),
381 }
382}
383
384pub async fn compaction(
385 Extension(_state): Extension<Arc<ServerState>>,
386 Extension(ctx): Extension<RequestContext>,
387) -> Response {
388 error_response(
389 crate::error::ServerError::NotImplemented(
390 "manual compaction is not available for the server's LSM storage engine".into(),
391 ),
392 &ctx,
393 )
394}
395
396pub async fn start_backup(
397 Extension(state): Extension<Arc<ServerState>>,
398 Extension(ctx): Extension<RequestContext>,
399) -> Response {
400 match state.backup_coordinator.start_backup().await {
401 Ok(handle) => match backup_response(&state, &handle) {
402 Ok(response) => Json(response).into_response(),
403 Err(err) => error_response(err, &ctx),
404 },
405 Err(err) => error_response(err, &ctx),
406 }
407}
408
409pub async fn export(
410 Extension(state): Extension<Arc<ServerState>>,
411 Extension(ctx): Extension<RequestContext>,
412) -> Response {
413 let export_state = state.clone();
414 let result = tokio::task::spawn_blocking(move || perform_export(export_state.as_ref()))
415 .await
416 .map_err(|err| crate::error::ServerError::Internal(err.to_string()))
417 .and_then(|res| res);
418
419 match result {
420 Ok(location) => Json(AdminExportResponse {
421 status: "OK",
422 location,
423 })
424 .into_response(),
425 Err(err) => error_response(err, &ctx),
426 }
427}
428
429pub async fn backup_status(
430 AxumPath(id): AxumPath<String>,
431 Extension(state): Extension<Arc<ServerState>>,
432 Extension(ctx): Extension<RequestContext>,
433) -> Response {
434 let handle = match parse_backup_handle(&id) {
435 Ok(handle) => handle,
436 Err(err) => return error_response(err, &ctx),
437 };
438 match backup_response(&state, &handle) {
439 Ok(response) => Json(response).into_response(),
440 Err(err) => error_response(err, &ctx),
441 }
442}
443
444pub async fn start_restore(
445 Extension(state): Extension<Arc<ServerState>>,
446 Extension(ctx): Extension<RequestContext>,
447 Json(request): Json<AdminRestoreRequest>,
448) -> Response {
449 let source_path = match request.source {
450 Some(source) => source.into(),
451 None => match crate::ops::restore::resolve_default_source(&state.config.data_dir) {
452 Ok(path) => path,
453 Err(crate::error::ServerError::NotFound(_)) => {
454 match state.backup_coordinator.latest_location() {
455 Some(path) => path,
456 None => {
457 let data_dir = state.config.data_dir.clone();
458 let archive_result = tokio::task::spawn_blocking(move || {
459 perform_lifecycle_action("archive", Path::new(&data_dir))
460 })
461 .await
462 .map_err(|err| crate::error::ServerError::Internal(err.to_string()))
463 .and_then(|res| res.map_err(crate::error::ServerError::BadRequest));
464 if let Err(err) = archive_result {
465 return error_response(err, &ctx);
466 }
467 match crate::ops::restore::resolve_default_source(&state.config.data_dir) {
468 Ok(path) => path,
469 Err(err) => return error_response(err, &ctx),
470 }
471 }
472 }
473 }
474 Err(err) => return error_response(err, &ctx),
475 },
476 };
477 let source = RestoreSource { path: source_path };
478 match state.restore_coordinator.start_restore(source).await {
479 Ok(handle) => match restore_response(&state, &handle) {
480 Ok(response) => Json(response).into_response(),
481 Err(err) => error_response(err, &ctx),
482 },
483 Err(err) => error_response(err, &ctx),
484 }
485}
486
487pub async fn restore_status(
488 AxumPath(id): AxumPath<String>,
489 Extension(state): Extension<Arc<ServerState>>,
490 Extension(ctx): Extension<RequestContext>,
491) -> Response {
492 let handle = match parse_restore_handle(&id) {
493 Ok(handle) => handle,
494 Err(err) => return error_response(err, &ctx),
495 };
496 match restore_response(&state, &handle) {
497 Ok(response) => Json(response).into_response(),
498 Err(err) => error_response(err, &ctx),
499 }
500}
501
502pub async fn lifecycle(
503 Extension(state): Extension<Arc<ServerState>>,
504 Json(request): Json<AdminLifecycleRequest>,
505) -> impl IntoResponse {
506 let data_dir = state.config.data_dir.clone();
507 let action = request.action;
508 let result = tokio::task::spawn_blocking(move || {
509 perform_lifecycle_action(action.as_str(), Path::new(&data_dir))
510 })
511 .await
512 .map_err(|err| err.to_string())
513 .and_then(|res| res.map_err(|err| err.to_string()));
514
515 match result {
516 Ok(message) => (
517 StatusCode::OK,
518 Json(AdminLifecycleResponse {
519 status: "OK",
520 message,
521 }),
522 )
523 .into_response(),
524 Err(err) => (
525 StatusCode::BAD_REQUEST,
526 Json(AdminLifecycleResponse {
527 status: "Error",
528 message: err,
529 }),
530 )
531 .into_response(),
532 }
533}
534
535fn parse_backup_handle(id: &str) -> crate::error::Result<BackupHandle> {
536 let id = Uuid::parse_str(id)
537 .map_err(|_| crate::error::ServerError::BadRequest("invalid backup handle".into()))?;
538 Ok(BackupHandle { id })
539}
540
541fn parse_restore_handle(id: &str) -> crate::error::Result<RestoreHandle> {
542 let id = Uuid::parse_str(id)
543 .map_err(|_| crate::error::ServerError::BadRequest("invalid restore handle".into()))?;
544 Ok(RestoreHandle { id })
545}
546
547fn backup_response(
548 state: &ServerState,
549 handle: &BackupHandle,
550) -> crate::error::Result<AdminBackupResponse> {
551 let location = state.backup_coordinator.location(handle)?;
552 let status = state.backup_coordinator.status(handle)?;
553 Ok(AdminBackupResponse {
554 handle: handle.id.to_string(),
555 location: location.display().to_string(),
556 state: status,
557 })
558}
559
560fn restore_response(
561 state: &ServerState,
562 handle: &RestoreHandle,
563) -> crate::error::Result<AdminRestoreResponse> {
564 let status = state.restore_coordinator.status(handle)?;
565 let metadata = state.restore_coordinator.metadata(handle)?;
566 Ok(AdminRestoreResponse {
567 handle: handle.id.to_string(),
568 state: status,
569 metadata,
570 })
571}
572
573fn capabilities_for_auth(
574 auth: &crate::auth::AuthMiddleware,
575) -> (&'static str, Vec<&'static str>, Vec<&'static str>) {
576 match auth.mode() {
577 AuthMode::None => ("full", Vec::new(), unsupported_actions()),
578 AuthMode::Dev { .. } => ("restricted", all_actions(), unsupported_actions()),
579 }
580}
581
582fn unsupported_actions() -> Vec<&'static str> {
583 vec!["compaction"]
584}
585
586fn all_actions() -> Vec<&'static str> {
587 vec![
588 "read", "create", "update", "delete", "archive", "restore", "backup", "export", "join",
589 "leave",
590 ]
591}
592
593fn cluster_operation_response(
594 state: &Arc<ServerState>,
595 ctx: &RequestContext,
596 action: &'static str,
597) -> Response {
598 let cluster = match action {
599 "join" => state.cluster_join(),
600 "leave" => state.cluster_leave(),
601 _ => unreachable!("cluster membership action is fixed by route"),
602 };
603 let cluster = match cluster {
604 Ok(snapshot) => snapshot,
605 Err(err) => return error_response(err, ctx),
606 };
607 state.metrics.record_cluster_status(&cluster);
608 Json(AdminClusterOperationResponse { action, cluster }).into_response()
609}
610
611fn perform_lifecycle_action(action: &str, data_dir: &Path) -> Result<String, String> {
612 if !data_dir.exists() {
613 return Err(format!(
614 "Data directory does not exist: {}",
615 data_dir.display()
616 ));
617 }
618 if !data_dir.is_dir() {
619 return Err(format!(
620 "Data directory is not a directory: {}",
621 data_dir.display()
622 ));
623 }
624
625 let lifecycle_root = data_dir.join(".lifecycle");
626 std::fs::create_dir_all(&lifecycle_root).map_err(|err| err.to_string())?;
627
628 match action {
629 "archive" => {
630 let dest = lifecycle_root.join("archive").join(timestamp_dir());
631 copy_data_dir(data_dir, &dest)?;
632 write_latest_marker(&lifecycle_root.join("archive"), &dest)?;
633 Ok(format!("Archived data to {}", dest.display()))
634 }
635 "export" => {
636 let dest = lifecycle_root.join("export").join(timestamp_dir());
637 copy_data_dir(data_dir, &dest)?;
638 write_latest_marker(&lifecycle_root.join("export"), &dest)?;
639 Ok(format!("Exported data to {}", dest.display()))
640 }
641 _ => Err("Unknown lifecycle action.".to_string()),
642 }
643}
644
645fn perform_export(state: &ServerState) -> crate::error::Result<String> {
646 match state.store.as_ref() {
647 AnyKV::Lsm(kv) => {
648 let _ = kv.checkpoint()?;
649 }
650 _ => {
651 return Err(crate::error::ServerError::BadRequest(
652 "checkpoint unsupported for current storage engine".to_string(),
653 ));
654 }
655 }
656 let data_dir = state.config.data_dir.as_path();
657 let lifecycle_root = data_dir.join(".lifecycle");
658 std::fs::create_dir_all(&lifecycle_root)?;
659 let dest = lifecycle_root.join("export").join(timestamp_dir());
660 std::fs::create_dir_all(&dest)?;
661 export_snapshot(data_dir, &dest)?;
662 write_latest_marker(&lifecycle_root.join("export"), &dest)
663 .map_err(crate::error::ServerError::Internal)?;
664 Ok(dest.display().to_string())
665}
666
667fn timestamp_dir() -> String {
668 let seconds = std::time::SystemTime::now()
669 .duration_since(std::time::UNIX_EPOCH)
670 .unwrap_or_default()
671 .as_secs();
672 format!("ts-{seconds}")
673}
674
675fn copy_data_dir(src: &Path, dest: &Path) -> Result<(), String> {
676 std::fs::create_dir_all(dest).map_err(|err| err.to_string())?;
677 copy_dir_filtered(src, dest).map_err(|err| err.to_string())
678}
679
680fn write_latest_marker(root: &Path, dest: &Path) -> Result<(), String> {
681 let marker = root.join("latest");
682 std::fs::create_dir_all(root).map_err(|err| err.to_string())?;
683 std::fs::write(&marker, dest.to_string_lossy().as_bytes()).map_err(|err| err.to_string())?;
684 Ok(())
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use alopex_cluster::{ClusterManager, ClusterManagerConfig};
691
692 #[test]
693 fn single_node_metadata_route_never_advertises_multi_node_control() {
694 let manager = ClusterManager::new(ClusterManagerConfig::single_node()).unwrap();
695 let availability = cluster_control_availability(&manager.status_snapshot()).unwrap();
696
697 assert!(!availability.available);
698 assert_eq!(availability.reason, "single_node_mode");
699 assert!(availability.missing_prerequisites.is_empty());
700 }
701
702 #[test]
703 fn only_read_operations_skip_explicit_mutation_confirmation() {
704 assert!(!AdminClusterManagementOperation::MetadataShow.is_mutation());
705 assert!(!AdminClusterManagementOperation::UpgradeStatus.is_mutation());
706 assert!(AdminClusterManagementOperation::RangesRegister.is_mutation());
707 assert!(AdminClusterManagementOperation::SchemaRolloutStart.is_mutation());
708 }
709}