Skip to main content

mongreldb_server/
cluster_admin.rs

1//! Cluster administration HTTP surface (spec section 11.1, S2A-002).
2//!
3//! The `/admin/cluster/*` endpoints expose the cluster crate's bootstrap and
4//! membership workflows over the daemon's authenticated admin channel:
5//!
6//! ```text
7//! GET  /admin/cluster/status        -> bootstrap::cluster_status
8//! POST /admin/cluster/node/drain    -> bootstrap::node_drain
9//! POST /admin/cluster/node/remove   -> bootstrap::node_remove
10//! ```
11//!
12//! The node data directory is the database directory (`Database::root`); the
13//! cluster crate keeps its durable records under `<db-dir>/cluster-meta/`. A
14//! server whose database directory carries no cluster identity keeps working
15//! exactly as before: the status endpoint reports `"standalone"` mode and the
16//! mutating endpoints answer 409 Conflict.
17//!
18//! The `node remove` confirmation token is never served over HTTP and never
19//! written to the audit log: operators obtain it out of band from the CLI
20//! (`mongreldb-server node remove --data-dir <dir>` prints it), per the
21//! cluster crate's contract. Trust material is equally strict — status
22//! responses carry the key-free [`bootstrap::TrustSummary`] view only.
23
24use std::sync::Arc;
25
26use axum::extract::State;
27use axum::http::StatusCode;
28use axum::response::{IntoResponse, Response};
29use axum::Json;
30use mongreldb_cluster::bootstrap::{self, ClusterStatus};
31use mongreldb_cluster::gateway::{self, AdminCommand, JobAction};
32use mongreldb_cluster::node::{ClusterError, NodeIdentity, VersionInfo};
33use mongreldb_types::ids::NodeId;
34use serde::Deserialize;
35use serde_json::{json, Value};
36
37use crate::{request_owner, require_admin, AppState, OptionalPrincipal};
38
39/// Serialize one status component; these plain-data cluster types always
40/// serialize, so a failure here is a bug, not an operator error.
41fn component<T: serde::Serialize>(value: &T) -> Value {
42    serde_json::to_value(value).expect("cluster status component serialization")
43}
44
45/// The key-free `cluster status` JSON view shared by the CLI and
46/// `GET /admin/cluster/status`: identity, membership, descriptors, and this
47/// binary's version advertisement (spec section 11.8).
48pub fn cluster_status_json(status: &ClusterStatus) -> Value {
49    let trust = status.trust.as_ref().map(|trust| {
50        json!({
51            "ca_cert_pem": component(&trust.ca_cert_pem),
52            "node_cert_pem": component(&trust.node_cert_pem),
53            "allowed_node_ids": component(&trust.allowed_node_ids),
54            "has_node_key": trust.has_node_key,
55        })
56    });
57    json!({
58        "mode": "cluster",
59        "identity": component(&status.identity),
60        "membership": component(&status.membership),
61        "member_endpoints": component(&status.member_endpoints),
62        "database_group": component(&status.database_group),
63        "trust": trust,
64        "version_info": component(&VersionInfo::current()),
65    })
66}
67
68/// The `cluster status` JSON view when the database directory holds no
69/// cluster identity: the server runs exactly as before, standalone.
70pub fn standalone_status_json() -> Value {
71    json!({
72        "mode": "standalone",
73        "detail": "no cluster identity in the database directory; \
74                   run `mongreldb-server cluster init` or `mongreldb-server cluster join` \
75                   to bootstrap one",
76        "version_info": component(&VersionInfo::current()),
77    })
78}
79
80/// `cluster status` as one JSON report: the cluster view when bootstrapped,
81/// the standalone view when no identity exists, or the underlying error
82/// (corrupt or unsupported metadata fails closed).
83pub fn status_report(node_data: &std::path::Path) -> Result<Value, ClusterError> {
84    match bootstrap::cluster_status(node_data) {
85        Ok(status) => Ok(cluster_status_json(&status)),
86        Err(ClusterError::NotInitialized) => Ok(standalone_status_json()),
87        Err(error) => Err(error),
88    }
89}
90
91/// Map a cluster workflow error onto an HTTP status: bad operator input is
92/// 400, a wrong confirmation token is 403, an unknown member is 404, state
93/// and bootstrap conflicts are 409, and everything else stays 500 (the same
94/// defense-in-depth shape as `status_for_error`).
95pub(crate) fn status_for_cluster_error(error: &ClusterError) -> StatusCode {
96    match error {
97        ClusterError::InvalidInvite(_) | ClusterError::InvalidTrustMaterial(_) => {
98            StatusCode::BAD_REQUEST
99        }
100        ClusterError::InvalidConfirmationToken => StatusCode::FORBIDDEN,
101        ClusterError::NodeNotFound { .. } => StatusCode::NOT_FOUND,
102        ClusterError::NotInitialized
103        | ClusterError::AlreadyBootstrapped { .. }
104        | ClusterError::ClusterIdentityMismatch { .. }
105        | ClusterError::InvalidNodeStateTransition { .. }
106        | ClusterError::BootstrapInProgress(_) => StatusCode::CONFLICT,
107        _ => StatusCode::INTERNAL_SERVER_ERROR,
108    }
109}
110
111/// Map a cluster workflow error onto an HTTP status + JSON error body.
112fn cluster_error_response(error: &ClusterError) -> Response {
113    (
114        status_for_cluster_error(error),
115        Json(json!({ "error": error.to_string() })),
116    )
117        .into_response()
118}
119
120/// Resolve the member a drain/remove targets: the explicit `node_id` request
121/// field, else this node's own persisted identity. A standalone node has no
122/// identity to default to, so the operation conflicts. The error response is
123/// boxed (same shape as `require_admin`).
124fn resolve_target_node(state: &AppState, requested: Option<&str>) -> Result<NodeId, Box<Response>> {
125    match requested {
126        Some(text) => text.parse::<NodeId>().map_err(|error| {
127            Box::new(
128                (
129                    StatusCode::BAD_REQUEST,
130                    Json(json!({ "error": format!("invalid node_id `{text}`: {error}") })),
131                )
132                    .into_response(),
133            )
134        }),
135        None => match NodeIdentity::load(state.db.root()) {
136            Ok(Some(identity)) => Ok(identity.node_id),
137            Ok(None) => Err(Box::new(
138                (
139                    StatusCode::CONFLICT,
140                    Json(json!({
141                        "error": "node is standalone; no cluster identity exists \
142                                  (run `mongreldb-server cluster init` or `cluster join` first)",
143                    })),
144                )
145                    .into_response(),
146            )),
147            Err(error) => Err(Box::new(cluster_error_response(&error))),
148        },
149    }
150}
151
152/// `GET /admin/cluster/status` — identity, membership, node descriptors, and
153/// version info; reports `standalone` when no cluster identity exists.
154/// When a live [`crate::cluster_runtime::ClusterRuntimeHandle`] is configured,
155/// the report also carries a `runtime` object (node id, RPC address, tablet
156/// count, meta present).
157pub(crate) async fn status(
158    State(state): State<Arc<AppState>>,
159    OptionalPrincipal(principal): OptionalPrincipal,
160) -> Response {
161    if let Err(response) = require_admin(&state, &principal, "admin.cluster.status") {
162        return *response;
163    }
164    let root = cluster_status_root(&state);
165    match status_report(root) {
166        Ok(mut report) => {
167            if let Some(runtime) = &state.cluster_runtime {
168                match runtime.runtime_status_json().await {
169                    Ok(runtime_status) => {
170                        if let Some(object) = report.as_object_mut() {
171                            object.insert("runtime".into(), runtime_status);
172                        }
173                    }
174                    Err(error) => {
175                        if let Some(object) = report.as_object_mut() {
176                            object.insert(
177                                "runtime".into(),
178                                json!({ "live": false, "error": error.to_string() }),
179                            );
180                        }
181                    }
182                }
183            }
184            Json(report).into_response()
185        }
186        Err(error) => {
187            state.audit.record(
188                request_owner(&state, &principal),
189                "admin.cluster.status.fail",
190                error.to_string(),
191            );
192            cluster_error_response(&error)
193        }
194    }
195}
196
197/// Prefer the live runtime's node-data directory when cluster mode is on;
198/// otherwise the database root (historical co-located layout).
199fn cluster_status_root(state: &AppState) -> &std::path::Path {
200    state
201        .cluster_runtime
202        .as_ref()
203        .map(|runtime| runtime.node_data())
204        .unwrap_or_else(|| state.db.root())
205}
206
207/// Fail-closed response when transfer/split/merge is issued without a live
208/// NodeRuntime (standalone default, or cluster mode not configured).
209fn runtime_not_running_response(command: &str, fields: Value) -> Response {
210    let mut body = fields;
211    if let Some(object) = body.as_object_mut() {
212        object.insert("command".into(), json!(command));
213        object.insert("status".into(), json!("error"));
214        object.insert("error".into(), json!("cluster runtime not running"));
215        object.insert("category".into(), json!("unavailable"));
216    }
217    (StatusCode::SERVICE_UNAVAILABLE, Json(body)).into_response()
218}
219
220/// Structured error from a live runtime operation (missing tablet, not leader, …).
221fn runtime_op_error_response(command: &str, error: String) -> Response {
222    (
223        StatusCode::CONFLICT,
224        Json(json!({
225            "command": command,
226            "status": "error",
227            "error": error,
228            "category": "failed_precondition",
229        })),
230    )
231        .into_response()
232}
233
234/// Optional body of `POST /admin/cluster/node/drain`: the member to move from
235/// `Up` to `Draining` (defaults to this node's own identity).
236#[derive(Deserialize)]
237pub(crate) struct NodeDrainRequest {
238    #[serde(default)]
239    node_id: Option<String>,
240}
241
242/// Body of `POST /admin/cluster/node/remove`: the member to decommission and
243/// the out-of-band confirmation token (never audited, never echoed back).
244#[derive(Deserialize)]
245pub(crate) struct NodeRemoveRequest {
246    #[serde(default)]
247    node_id: Option<String>,
248    #[serde(default)]
249    confirm_token: Option<String>,
250}
251
252/// `POST /admin/cluster/node/drain` — move a member from `Up` to `Draining`
253/// in the persisted membership record (audited; defaults to this node).
254pub(crate) async fn drain(
255    State(state): State<Arc<AppState>>,
256    OptionalPrincipal(principal): OptionalPrincipal,
257    body: Option<Json<NodeDrainRequest>>,
258) -> Response {
259    let owner = match require_admin(&state, &principal, "admin.cluster.drain") {
260        Ok(owner) => owner,
261        Err(response) => return *response,
262    };
263    let requested = body.and_then(|Json(request)| request.node_id);
264    let node_id = match resolve_target_node(&state, requested.as_deref()) {
265        Ok(node_id) => node_id,
266        Err(response) => return *response,
267    };
268    state.audit.record(
269        owner.clone(),
270        "admin.cluster.drain",
271        format!("initiated node_id={node_id}"),
272    );
273    match bootstrap::node_drain(state.db.root(), node_id) {
274        Ok(descriptor) => {
275            state.audit.record(
276                owner,
277                "admin.cluster.drain.ok",
278                format!("node_id={node_id} state={}", descriptor.state),
279            );
280            Json(json!({ "member": component(&descriptor) })).into_response()
281        }
282        Err(error) => {
283            state.audit.record(
284                owner,
285                "admin.cluster.drain.fail",
286                format!("node_id={node_id} {error}"),
287            );
288            cluster_error_response(&error)
289        }
290    }
291}
292
293/// `POST /admin/cluster/node/remove` — move a member to `Decommissioned` in
294/// the persisted membership record. Requires the out-of-band confirmation
295/// token in the request body; the token is never written to the audit log
296/// and never echoed in responses.
297pub(crate) async fn remove(
298    State(state): State<Arc<AppState>>,
299    OptionalPrincipal(principal): OptionalPrincipal,
300    body: Option<Json<NodeRemoveRequest>>,
301) -> Response {
302    let owner = match require_admin(&state, &principal, "admin.cluster.remove") {
303        Ok(owner) => owner,
304        Err(response) => return *response,
305    };
306    let (requested, confirm_token) = match body {
307        Some(Json(request)) => (request.node_id, request.confirm_token),
308        None => (None, None),
309    };
310    let Some(confirm_token) = confirm_token else {
311        return (
312            StatusCode::BAD_REQUEST,
313            Json(json!({
314                "error": "confirm_token is required; obtain it out of band via \
315                          `mongreldb-server node remove --data-dir <dir>`",
316            })),
317        )
318            .into_response();
319    };
320    let node_id = match resolve_target_node(&state, requested.as_deref()) {
321        Ok(node_id) => node_id,
322        Err(response) => return *response,
323    };
324    state.audit.record(
325        owner.clone(),
326        "admin.cluster.remove",
327        format!("initiated node_id={node_id}"),
328    );
329    match bootstrap::node_remove(state.db.root(), node_id, &confirm_token) {
330        Ok(descriptor) => {
331            state.audit.record(
332                owner,
333                "admin.cluster.remove.ok",
334                format!("node_id={node_id} state={}", descriptor.state),
335            );
336            Json(json!({ "member": component(&descriptor) })).into_response()
337        }
338        Err(error) => {
339            state.audit.record(
340                owner,
341                "admin.cluster.remove.fail",
342                format!("node_id={node_id} {error}"),
343            );
344            cluster_error_response(&error)
345        }
346    }
347}
348
349// ---------------------------------------------------------------------------
350// Admin SQL surface (§15) — typed commands from gateway::parse_admin_sql
351// ---------------------------------------------------------------------------
352
353/// Try to handle `sql` as a §15 cluster admin statement.
354///
355/// Returns `None` when the text is ordinary SQL (caller falls through).
356/// Returns `Some(response)` for recognised admin commands. SHOW helpers are
357/// available without requiring a fully-booted tablet runtime. Mutating
358/// commands that need live groups (`TRANSFER LEADER`, `SPLIT TABLET`,
359/// `MERGE TABLETS`) fail closed with `"cluster runtime not running"` when
360/// no [`crate::cluster_runtime::ClusterRuntimeHandle`] is configured, and
361/// drive the live runtime when it is. `MOVE REPLICA` returns a clear
362/// not-yet-live error while a runtime is present (no direct placement API
363/// on the product path yet) and the same fail-closed error when absent.
364/// `ALTER NODE DRAIN` reuses the existing bootstrap path.
365pub(crate) async fn try_admin_sql(
366    state: &AppState,
367    principal: &Option<mongreldb_core::Principal>,
368    sql: &str,
369) -> Option<Response> {
370    let command = match gateway::parse_admin_sql(sql) {
371        Ok(Some(cmd)) => cmd,
372        Ok(None) => return None,
373        Err(error) => {
374            return Some(
375                (
376                    StatusCode::BAD_REQUEST,
377                    Json(json!({ "error": error.to_string(), "category": "invalid_argument" })),
378                )
379                    .into_response(),
380            );
381        }
382    };
383
384    // All admin SQL requires the admin principal (spec §15: authenticated +
385    // authorized). Audit every attempt.
386    let owner = match require_admin(state, principal, "admin.sql") {
387        Ok(owner) => owner,
388        Err(response) => return Some(*response),
389    };
390    state.audit.record(
391        owner.clone(),
392        "admin.sql",
393        format!("command={}", admin_command_name(&command)),
394    );
395
396    let status_root = cluster_status_root(state);
397    let response = match command {
398        AdminCommand::ShowCluster => match status_report(status_root) {
399            Ok(mut report) => {
400                // Stage 4 multi-region policy is server-reachable via admin SQL.
401                let multi = state
402                    .multi_region
403                    .lock()
404                    .map(|p| {
405                        let placement =
406                            mongreldb_cluster::multi_region::placement_from_multi_region(&p);
407                        json!({
408                            "prefer_home_leader": p.prefer_home_leader,
409                            "regional_followers": p.regional_followers,
410                            "async_dr_regions": p.async_dr_regions,
411                            "tenant_home_region": p.tenant_home_region,
412                            "total_voters": p.voters.total_voters(),
413                            "placement_replicas": placement.replicas,
414                            "multi_leader_default": false,
415                        })
416                    })
417                    .unwrap_or_else(|_| json!({ "error": "multi_region lock poisoned" }));
418                if let Some(runtime) = &state.cluster_runtime {
419                    match runtime.runtime_status_json().await {
420                        Ok(runtime_status) => {
421                            if let Some(object) = report.as_object_mut() {
422                                object.insert("runtime".into(), runtime_status);
423                            }
424                        }
425                        Err(error) => {
426                            if let Some(object) = report.as_object_mut() {
427                                object.insert(
428                                    "runtime".into(),
429                                    json!({ "live": false, "error": error.to_string() }),
430                                );
431                            }
432                        }
433                    }
434                }
435                Json(json!({
436                    "command": "SHOW CLUSTER",
437                    "result": report,
438                    "multi_region": multi,
439                }))
440                .into_response()
441            }
442            Err(error) => cluster_error_response(&error),
443        },
444        AdminCommand::ShowNodes => match bootstrap::cluster_status(status_root) {
445            Ok(status) => Json(json!({
446                "command": "SHOW NODES",
447                "nodes": component(&status.member_endpoints),
448                "membership": component(&status.membership),
449            }))
450            .into_response(),
451            Err(ClusterError::NotInitialized) => Json(json!({
452                "command": "SHOW NODES",
453                "mode": "standalone",
454                "nodes": [],
455            }))
456            .into_response(),
457            Err(error) => cluster_error_response(&error),
458        },
459        AdminCommand::ShowTablets { table } => {
460            let (tablets, issues) = mongreldb_cluster::tablet::list_tablets_on_disk(status_root)
461                .unwrap_or_else(|e| (Vec::new(), vec![e.to_string()]));
462            let rows: Vec<Value> = tablets
463                .iter()
464                .filter(|t| {
465                    // Optional name filter is best-effort (table id hex / display).
466                    table.as_ref().is_none_or(|name| {
467                        t.table_id.to_string() == *name || name.eq_ignore_ascii_case("all")
468                    })
469                })
470                .map(|t| {
471                    json!({
472                        "tablet_id": t.tablet_id.to_string(),
473                        "table_id": t.table_id.to_string(),
474                        "raft_group_id": t.raft_group_id.to_string(),
475                        "generation": t.generation,
476                        "state": t.state.to_string(),
477                        "replicas": t.replicas.len(),
478                        "leader_hint": t.leader_hint.map(|n| n.to_string()),
479                    })
480                })
481                .collect();
482            Json(json!({
483                "command": "SHOW TABLETS",
484                "table": table,
485                "tablets": rows,
486                "issues": issues,
487            }))
488            .into_response()
489        }
490        AdminCommand::ShowReplicas { tablet_id } => {
491            let (tablets, issues) = mongreldb_cluster::tablet::list_tablets_on_disk(status_root)
492                .unwrap_or_else(|e| (Vec::new(), vec![e.to_string()]));
493            let mut replicas = Vec::new();
494            for t in &tablets {
495                if tablet_id.is_some_and(|id| id != t.tablet_id) {
496                    continue;
497                }
498                for r in &t.replicas {
499                    replicas.push(json!({
500                        "tablet_id": t.tablet_id.to_string(),
501                        "node_id": r.node_id.to_string(),
502                        "role": r.role.to_string(),
503                        "raft_node_id": r.raft_node_id,
504                        "counts_toward_quorum": r.role.counts_toward_quorum(),
505                    }));
506                }
507            }
508            Json(json!({
509                "command": "SHOW REPLICAS",
510                "tablet_id": tablet_id.map(|id| id.to_string()),
511                "replicas": replicas,
512                "issues": issues,
513            }))
514            .into_response()
515        }
516        AdminCommand::ShowTransactions => {
517            // Live sessions stand in for open interactive transactions; each
518            // carries owner + idle bookkeeping via the session store.
519            Json(json!({
520                "command": "SHOW TRANSACTIONS",
521                "open_sessions": state.sessions.len(),
522                "transactions": [],
523                "note": "distributed txn status partitions surface when a meta/txn group is hosted; session count is live",
524            }))
525            .into_response()
526        }
527        AdminCommand::ShowQueries => {
528            let queries: Vec<Value> = state
529                .query_registry
530                .active_statuses()
531                .into_iter()
532                .map(|q| {
533                    json!({
534                        "query_id": format!("{}", q.query_id),
535                        "owner": q.owner,
536                        "session_id": q.session_id,
537                        "phase": format!("{:?}", q.phase),
538                        "operation": q.operation,
539                    })
540                })
541                .collect();
542            Json(json!({
543                "command": "SHOW QUERIES",
544                "queries": queries,
545                "active_count": state.query_registry.active_count(),
546            }))
547            .into_response()
548        }
549        AdminCommand::ShowJobs => {
550            let engine_jobs: Vec<Value> = state
551                .db
552                .job_registry()
553                .list()
554                .into_iter()
555                .map(|j| {
556                    json!({
557                        "job_id": j.job_id,
558                        "kind": format!("{:?}", j.kind),
559                        "state": format!("{:?}", j.state),
560                        "source": "engine",
561                    })
562                })
563                .collect();
564            let ops: Vec<Value> = state
565                .ops_jobs
566                .lock()
567                .map(|s| {
568                    s.list()
569                        .into_iter()
570                        .map(|j| {
571                            json!({
572                                "job_id": j.job_id,
573                                "kind": j.kind.name(),
574                                "state": format!("{:?}", j.state),
575                                "phase": j.phase,
576                                "progress": j.progress,
577                                "source": "ops",
578                            })
579                        })
580                        .collect()
581                })
582                .unwrap_or_default();
583            Json(json!({
584                "command": "SHOW JOBS",
585                "jobs": engine_jobs.into_iter().chain(ops).collect::<Vec<_>>(),
586            }))
587            .into_response()
588        }
589        AdminCommand::ShowResourceGroups => {
590            let stats = Some(state.scheduler.stats());
591            // Drive node governor with live inputs and apply actions (S4B).
592            let governor = state.node_governor.lock().ok().map(|mut gov| {
593                let db_gov = state.db.memory_governor();
594                let ai_capacity = std::env::var("MONGRELDB_AI_MAX_CONCURRENT")
595                    .ok()
596                    .and_then(|v| v.parse().ok())
597                    .filter(|v: &usize| *v > 0)
598                    .unwrap_or(4);
599                let inputs = crate::admission::build_pressure_inputs(
600                    &crate::admission::PressureInputSources {
601                        db_reserved_bytes: db_gov.total_used(),
602                        db_max_bytes: db_gov.max_bytes(),
603                        node_configured_max_bytes: gov.governor.max_bytes(),
604                        tablet_reserved_bytes: gov.tablet_reserved_bytes(),
605                        ai_capacity,
606                        ai_available: state.ai_semaphore.available_permits(),
607                        process_rss_bytes: crate::admission::process_rss_bytes(),
608                    },
609                );
610                let actions = crate::admission::refresh_pressure(
611                    &mut gov,
612                    &inputs,
613                    &state.scheduler,
614                    Some(db_gov),
615                );
616                let pressure = state.scheduler.pressure().snapshot();
617                json!({
618                    "tablet_reserved_bytes": gov.tablet_reserved_bytes(),
619                    "query_reserved_bytes": inputs.query_reserved_bytes,
620                    "configured_max_bytes": inputs.configured_max_bytes,
621                    "os_pressure": inputs.os_pressure,
622                    "actions": actions.iter().map(|a| format!("{a:?}")).collect::<Vec<_>>(),
623                    "applied": {
624                        "reject_ai": pressure.reject_ai,
625                        "reduced_admission": pressure.reduced_admission,
626                        "move_tablet_noops": pressure.move_tablet_noops,
627                        "last_evict_bytes": pressure.last_evict_bytes,
628                        "evaluate_count": pressure.evaluate_count,
629                    },
630                    // MoveTabletLeaders is a no-op outside cluster tablet routing.
631                    "move_tablet_leaders": "noop_outside_cluster_mode",
632                })
633            });
634            // AI index readiness registry + retrieval planner knobs (S4C/S4D).
635            let ai = state.ai_generations.lock().ok().map(|reg| {
636                let local_k = mongreldb_query::adaptive_local_k(10, 2.0, 5);
637                let readiness = mongreldb_core::readiness_action(true, true, false);
638                json!({
639                    "adaptive_local_k_example": local_k,
640                    "fusion_default": "rrf_k60",
641                    "indexes_registered": reg.len(),
642                    "readiness_action_example": format!("{readiness:?}"),
643                })
644            });
645            let groups: Vec<Value> = state
646                .resource_groups
647                .names()
648                .into_iter()
649                .filter_map(|name| {
650                    state.resource_groups.get(&name).map(|g| {
651                        json!({
652                            "name": g.name,
653                            "max_concurrency": g.max_concurrency,
654                            "max_queue": g.max_queue,
655                            "memory_bytes": g.memory_bytes,
656                            "temporary_disk_bytes": g.temporary_disk_bytes,
657                            "work_units": g.work_units,
658                            "cpu_weight": g.cpu_weight,
659                            "priority": g.priority,
660                            "max_result_bytes": g.max_result_bytes,
661                        })
662                    })
663                })
664                .collect();
665            let embedding_providers = state.embedding_providers.list_ids();
666            Json(json!({
667                "command": "SHOW RESOURCE GROUPS",
668                "resource_groups": groups,
669                "workload_classes": mongreldb_core::WorkloadClass::ALL
670                    .iter()
671                    .map(|c| c.name())
672                    .collect::<Vec<_>>(),
673                "embedding_providers": embedding_providers,
674                "scheduler": stats.map(|s| json!({
675                    "tenants": s.tenants,
676                    "per_class": s.per_class,
677                })),
678                "node_governor": governor,
679                "ai": ai,
680            }))
681            .into_response()
682        }
683        AdminCommand::ShowBackups => {
684            let backups: Vec<Value> = state
685                .ops_jobs
686                .lock()
687                .map(|s| {
688                    s.list()
689                        .into_iter()
690                        .filter(|j| j.kind == mongreldb_core::OpsJobKind::Backup)
691                        .map(|j| {
692                            json!({
693                                "job_id": j.job_id,
694                                "state": format!("{:?}", j.state),
695                                "phase": j.phase,
696                                "progress": j.progress,
697                                "destination": j.params.get("destination"),
698                            })
699                        })
700                        .collect()
701                })
702                .unwrap_or_default();
703            Json(json!({
704                "command": "SHOW BACKUPS",
705                "backups": backups,
706            }))
707            .into_response()
708        }
709        AdminCommand::AlterNodeDrain { node_id } => {
710            state.audit.record(
711                owner.clone(),
712                "admin.sql.drain",
713                format!("node_id={node_id}"),
714            );
715            match bootstrap::node_drain(status_root, node_id) {
716                Ok(descriptor) => {
717                    state.audit.record(
718                        owner,
719                        "admin.sql.drain.ok",
720                        format!("node_id={node_id} state={}", descriptor.state),
721                    );
722                    Json(json!({
723                        "command": "ALTER NODE DRAIN",
724                        "member": component(&descriptor),
725                    }))
726                    .into_response()
727                }
728                Err(error) => {
729                    state.audit.record(
730                        owner,
731                        "admin.sql.drain.fail",
732                        format!("node_id={node_id} {error}"),
733                    );
734                    cluster_error_response(&error)
735                }
736            }
737        }
738        AdminCommand::TransferLeader { tablet_id, to } => match &state.cluster_runtime {
739            None => runtime_not_running_response(
740                "TRANSFER LEADER",
741                json!({
742                    "tablet_id": tablet_id.to_string(),
743                    "to": to.to_string(),
744                }),
745            ),
746            Some(runtime) => match runtime.transfer_leader(tablet_id, to).await {
747                Ok(body) => Json(body).into_response(),
748                Err(error) => runtime_op_error_response("TRANSFER LEADER", error.to_string()),
749            },
750        },
751        AdminCommand::MoveReplica {
752            tablet_id,
753            from,
754            to,
755        } => {
756            // No direct placement call on NodeRuntime yet; fail closed with a
757            // clear status whether or not the runtime is live so operators are
758            // never told the move was accepted.
759            let detail = if state.cluster_runtime.is_some() {
760                "move replica is not yet live on the product path"
761            } else {
762                "cluster runtime not running"
763            };
764            (
765                StatusCode::SERVICE_UNAVAILABLE,
766                Json(json!({
767                    "command": "MOVE REPLICA",
768                    "tablet_id": tablet_id.to_string(),
769                    "from": from.to_string(),
770                    "to": to.to_string(),
771                    "status": "error",
772                    "error": detail,
773                    "category": "unavailable",
774                })),
775            )
776                .into_response()
777        }
778        AdminCommand::SplitTablet {
779            tablet_id,
780            at_key_hex,
781        } => match &state.cluster_runtime {
782            None => runtime_not_running_response(
783                "SPLIT TABLET",
784                json!({
785                    "tablet_id": tablet_id.to_string(),
786                    "at_key_hex": at_key_hex,
787                }),
788            ),
789            Some(runtime) => match runtime.split_tablet(tablet_id, at_key_hex).await {
790                Ok(body) => Json(body).into_response(),
791                Err(error) => runtime_op_error_response("SPLIT TABLET", error.to_string()),
792            },
793        },
794        AdminCommand::MergeTablets { left, right } => match &state.cluster_runtime {
795            None => runtime_not_running_response(
796                "MERGE TABLETS",
797                json!({
798                    "left": left.to_string(),
799                    "right": right.to_string(),
800                }),
801            ),
802            Some(runtime) => match runtime.merge_tablets(left, right).await {
803                Ok(body) => Json(body).into_response(),
804                Err(error) => runtime_op_error_response("MERGE TABLETS", error.to_string()),
805            },
806        },
807        AdminCommand::JobControl { action, job_id } => {
808            let verb = match action {
809                JobAction::Pause => "PAUSE",
810                JobAction::Resume => "RESUME",
811                JobAction::Cancel => "CANCEL",
812            };
813            // Fail closed: never report accepted unless the ops store applied
814            // the transition (review finding: silent accepted stubs).
815            match action {
816                JobAction::Cancel => {
817                    return Some(
818                        (
819                            StatusCode::SERVICE_UNAVAILABLE,
820                            Json(json!({
821                                "error": "cancel job is not yet live on the product path",
822                                "command": format!("{verb} JOB"),
823                                "job_id": job_id,
824                                "category": "resource exhausted",
825                                "category_code": 18,
826                            })),
827                        )
828                            .into_response(),
829                    );
830                }
831                JobAction::Pause | JobAction::Resume => {
832                    let result = state.ops_jobs.lock().map(|mut store| match action {
833                        JobAction::Pause => store.pause(&job_id).map(|j| j.state),
834                        JobAction::Resume => store.start(&job_id).map(|j| j.state),
835                        JobAction::Cancel => unreachable!(),
836                    });
837                    match result {
838                        Ok(Ok(job_state)) => Json(json!({
839                            "command": format!("{verb} JOB"),
840                            "job_id": job_id,
841                            "status": "ok",
842                            "state": format!("{job_state:?}"),
843                        }))
844                        .into_response(),
845                        Ok(Err(error)) => (
846                            StatusCode::BAD_REQUEST,
847                            Json(json!({
848                                "error": error.to_string(),
849                                "command": format!("{verb} JOB"),
850                                "job_id": job_id,
851                            })),
852                        )
853                            .into_response(),
854                        Err(_) => (
855                            StatusCode::INTERNAL_SERVER_ERROR,
856                            Json(json!({
857                                "error": "ops job store lock poisoned",
858                                "command": format!("{verb} JOB"),
859                                "job_id": job_id,
860                            })),
861                        )
862                            .into_response(),
863                    }
864                }
865            }
866        }
867        AdminCommand::BackupDatabase { destination } => {
868            let mut params = std::collections::BTreeMap::new();
869            if let Some(dest) = &destination {
870                params.insert("destination".into(), dest.clone());
871            }
872            let job = state
873                .ops_jobs
874                .lock()
875                .map(|mut store| store.submit(mongreldb_core::OpsJobKind::Backup, params))
876                .ok();
877            // Do not fire-and-forget HierarchicalScheduler::submit here: orphan
878            // Backup work items steal fairness slots until a later poll/complete
879            // (review finding). Ops store owns the job lifecycle.
880            Json(json!({
881                "command": "BACKUP DATABASE",
882                "destination": destination,
883                "status": "accepted",
884                "job": job.map(|j| json!({
885                    "job_id": j.job_id,
886                    "state": format!("{:?}", j.state),
887                })),
888                "note": "cluster backup protocol is mongreldb_cluster::cluster_backup; job is resumable via ops store",
889            }))
890            .into_response()
891        }
892        AdminCommand::RestoreDatabase {
893            source,
894            disaster_recovery,
895        } => {
896            use mongreldb_cluster::cluster_backup::{
897                load_manifest, plan_restore, RestoreIdentityMode,
898            };
899            use mongreldb_types::ids::{ClusterId, DatabaseId};
900
901            let mut params = std::collections::BTreeMap::new();
902            params.insert("source".into(), source.clone());
903            params.insert("disaster_recovery".into(), disaster_recovery.to_string());
904            let job = state.ops_jobs.lock().ok().map(|mut store| {
905                store.submit(mongreldb_core::OpsJobKind::RestoreVerification, params)
906            });
907
908            // Live plan from a published backup when the path exists; otherwise
909            // surface the load error without pretending restore completed.
910            let plan_json = match load_manifest(std::path::Path::new(&source)) {
911                Ok(manifest) => {
912                    let mode = if disaster_recovery {
913                        RestoreIdentityMode::DisasterRecovery
914                    } else {
915                        RestoreIdentityMode::NewIdentity
916                    };
917                    let fresh = if disaster_recovery {
918                        None
919                    } else {
920                        Some((ClusterId::new_random(), DatabaseId::new_random()))
921                    };
922                    match plan_restore(&manifest, mode, fresh) {
923                        Ok(plan) => json!({
924                            "identity_mode": plan.identity_mode.to_string(),
925                            "target_cluster_id": plan.target_cluster_id.to_string(),
926                            "target_database_id": plan.target_database_id.to_string(),
927                            "source_cluster_id": plan.source_cluster_id.to_string(),
928                            "source_database_id": plan.source_database_id.to_string(),
929                            "tablet_count": plan.tablets.len(),
930                        }),
931                        Err(error) => json!({ "error": error.to_string() }),
932                    }
933                }
934                Err(error) => json!({
935                    "error": error.to_string(),
936                    "note": "ops job still accepted; materialize backup path then resume job",
937                }),
938            };
939
940            // No fire-and-forget scheduler submit (see BACKUP DATABASE).
941            Json(json!({
942                "command": "RESTORE DATABASE",
943                "source": source,
944                "disaster_recovery": disaster_recovery,
945                "status": "accepted",
946                "job": job.map(|j| json!({
947                    "job_id": j.job_id,
948                    "kind": j.kind.name(),
949                    "state": format!("{:?}", j.state),
950                })),
951                "restore_plan": plan_json,
952            }))
953            .into_response()
954        }
955    };
956    Some(response)
957}
958
959fn admin_command_name(command: &AdminCommand) -> &'static str {
960    match command {
961        AdminCommand::ShowCluster => "SHOW CLUSTER",
962        AdminCommand::ShowNodes => "SHOW NODES",
963        AdminCommand::ShowTablets { .. } => "SHOW TABLETS",
964        AdminCommand::ShowReplicas { .. } => "SHOW REPLICAS",
965        AdminCommand::ShowTransactions => "SHOW TRANSACTIONS",
966        AdminCommand::ShowQueries => "SHOW QUERIES",
967        AdminCommand::ShowJobs => "SHOW JOBS",
968        AdminCommand::ShowResourceGroups => "SHOW RESOURCE GROUPS",
969        AdminCommand::ShowBackups => "SHOW BACKUPS",
970        AdminCommand::AlterNodeDrain { .. } => "ALTER NODE DRAIN",
971        AdminCommand::TransferLeader { .. } => "TRANSFER LEADER",
972        AdminCommand::MoveReplica { .. } => "MOVE REPLICA",
973        AdminCommand::SplitTablet { .. } => "SPLIT TABLET",
974        AdminCommand::MergeTablets { .. } => "MERGE TABLETS",
975        AdminCommand::JobControl { .. } => "JOB CONTROL",
976        AdminCommand::BackupDatabase { .. } => "BACKUP DATABASE",
977        AdminCommand::RestoreDatabase { .. } => "RESTORE DATABASE",
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use mongreldb_cluster::gateway::parse_admin_sql;
984
985    #[test]
986    fn admin_sql_parser_covers_section_15_surface() {
987        for sql in [
988            "SHOW CLUSTER",
989            "SHOW NODES",
990            "SHOW TABLETS",
991            "SHOW REPLICAS",
992            "SHOW TRANSACTIONS",
993            "SHOW QUERIES",
994            "SHOW JOBS",
995            "SHOW RESOURCE GROUPS",
996            "SHOW BACKUPS",
997            "BACKUP DATABASE",
998        ] {
999            let cmd = parse_admin_sql(sql).unwrap();
1000            assert!(cmd.is_some(), "expected admin command for {sql}");
1001        }
1002        assert!(parse_admin_sql("SELECT 1").unwrap().is_none());
1003    }
1004}