1use std::{
2 collections::HashMap,
3 fmt, fs,
4 io::Write,
5 path::{Path, PathBuf},
6 sync::atomic::{AtomicBool, AtomicUsize, Ordering},
7 sync::Arc,
8};
9
10use axum::{
11 extract::{rejection::JsonRejection, Extension, Request, State},
12 http::StatusCode,
13 middleware::{self, Next},
14 response::{IntoResponse, Response},
15 routing::{get, post},
16 Json, Router,
17};
18use rhiza_core::{ConfigChange, ExecutionProfile, LogAnchor, LogEntry, LogHash, StoredCommand};
19use rhiza_log::{IndexRange, LogStore};
20use rhiza_quepaxa::{Membership, RecorderFileStore};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24use crate::{
25 client_authenticated, install_successor_recorder, valid_auth_token, ConfigError, NodeError,
26 NodeRuntime, NodeStatus, StopInformation,
27};
28use crate::{CheckpointCoordinator, DurabilityError};
29
30pub const ADMIN_STATUS_PATH: &str = "/v1/admin/membership/status";
31pub const ADMIN_STOP_PATH: &str = "/v1/admin/membership/stop";
32pub const ADMIN_INSTALL_SUCCESSOR_PATH: &str = "/v1/admin/membership/install-successor";
33pub const ADMIN_ACTIVATE_PATH: &str = "/v1/admin/membership/activate";
34pub const ADMIN_COMPACT_PATH: &str = "/v1/admin/checkpoint/compact";
35
36#[derive(Clone, Eq, PartialEq)]
37pub struct AdminConfig {
38 token: String,
39}
40
41impl AdminConfig {
42 pub fn new(token: impl Into<String>) -> Result<Self, ConfigError> {
43 let token = token.into();
44 if !valid_auth_token(&token) {
45 return Err(ConfigError::EmptyAdminToken);
46 }
47 Ok(Self { token })
48 }
49}
50
51impl fmt::Debug for AdminConfig {
52 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 formatter
54 .debug_struct("AdminConfig")
55 .field("token", &"[redacted]")
56 .finish()
57 }
58}
59
60#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
61pub struct AdminStatusResponse {
62 pub cluster_id: String,
63 pub execution_profile: ExecutionProfile,
64 pub epoch: u64,
65 pub node: NodeStatus,
66 pub members: Vec<String>,
67 pub recovery_generation: u64,
68 pub qlog_root: LogAnchor,
69 pub checkpoint_root: Option<LogAnchor>,
70 pub stopped_transition: Option<AdminStoppedTransition>,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
74#[serde(deny_unknown_fields)]
75pub struct AdminStopRequest {
76 pub operation_id: String,
77 pub expected_config_id: u64,
78 pub successor: AdminSuccessorBundle,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
82pub struct AdminStopResponse {
83 pub operation_id: String,
84 pub stop: StopInformation,
85 pub successor: AdminSuccessorBundle,
86}
87
88#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
89pub struct AdminStoppedTransition {
90 pub stop: StopInformation,
91 pub successor: AdminSuccessorBundle,
92}
93
94#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
95#[serde(deny_unknown_fields)]
96pub struct AdminSuccessorBundle {
97 pub config_id: u64,
98 pub members: Vec<String>,
99 pub digest: LogHash,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
103#[serde(deny_unknown_fields)]
104pub struct AdminInstallSuccessorRequest {
105 pub operation_id: String,
106 pub expected_config_id: u64,
107 pub expected_stopped_anchor: LogAnchor,
108 pub old_members: Vec<String>,
109 pub stop: StopInformation,
110 pub successor: AdminSuccessorBundle,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
114pub struct AdminInstallSuccessorResponse {
115 pub operation_id: String,
116 pub config_id: u64,
117 pub digest: LogHash,
118 pub activated: bool,
119}
120
121#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
122#[serde(deny_unknown_fields)]
123pub struct AdminActivateRequest {
124 pub operation_id: String,
125 pub expected_config_id: u64,
126}
127
128#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
129pub struct AdminActivateResponse {
130 pub operation_id: String,
131 pub entry: LogEntry,
132}
133
134#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
135#[serde(deny_unknown_fields)]
136pub struct AdminCompactRequest {
137 pub operation_id: String,
138 pub expected_config_id: u64,
139 pub expected_recovery_generation: u64,
140 pub expected_root: LogAnchor,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
144pub struct AdminCompactResponse {
145 pub operation_id: String,
146 pub anchor: rhiza_core::RecoveryAnchor,
147}
148
149#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
150#[serde(rename_all = "snake_case")]
151pub enum AdminErrorCode {
152 Unauthorized,
153 InvalidRequest,
154 OperationConflict,
155 PreconditionFailed,
156 Unavailable,
157 Internal,
158}
159
160#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
161pub struct AdminErrorResponse {
162 pub code: AdminErrorCode,
163}
164
165#[derive(Clone)]
166struct AdminGateState {
167 token: String,
168 admission: Arc<tokio::sync::Semaphore>,
169 tasks: AdminTaskTracker,
170}
171
172#[derive(Clone)]
173pub struct AdminTaskTracker {
174 state: Arc<AdminTaskState>,
175}
176
177struct AdminTaskState {
178 accepting: AtomicBool,
179 active: AtomicUsize,
180 changed: tokio::sync::watch::Sender<()>,
181}
182
183impl AdminTaskTracker {
184 fn new() -> Self {
185 let (changed, _) = tokio::sync::watch::channel(());
186 Self {
187 state: Arc::new(AdminTaskState {
188 accepting: AtomicBool::new(true),
189 active: AtomicUsize::new(0),
190 changed,
191 }),
192 }
193 }
194
195 fn try_start(&self) -> Option<AdminTaskGuard> {
196 if !self.state.accepting.load(Ordering::Acquire) {
197 return None;
198 }
199 self.state.active.fetch_add(1, Ordering::AcqRel);
200 if self.state.accepting.load(Ordering::Acquire) {
201 Some(AdminTaskGuard {
202 state: Arc::clone(&self.state),
203 })
204 } else {
205 finish_admin_task(&self.state);
206 None
207 }
208 }
209
210 pub fn stop_admission(&self) {
211 self.state.accepting.store(false, Ordering::Release);
212 }
213
214 pub async fn wait_for_idle(&self) {
215 let mut changed = self.state.changed.subscribe();
216 loop {
217 if self.state.active.load(Ordering::Acquire) == 0 {
218 return;
219 }
220 if changed.changed().await.is_err() {
221 return;
222 }
223 }
224 }
225}
226
227struct AdminTaskGuard {
228 state: Arc<AdminTaskState>,
229}
230
231impl Drop for AdminTaskGuard {
232 fn drop(&mut self) {
233 finish_admin_task(&self.state);
234 }
235}
236
237fn finish_admin_task(state: &AdminTaskState) {
238 if state.active.fetch_sub(1, Ordering::AcqRel) == 1 {
239 state.changed.send_replace(());
240 }
241}
242
243struct AdminPermit {
244 _admission: tokio::sync::OwnedSemaphorePermit,
245 _task: AdminTaskGuard,
246}
247
248#[derive(Clone)]
249struct AdminRouteState {
250 runtime: Arc<NodeRuntime>,
251 recorder: RecorderFileStore,
252 coordinator: Option<Arc<CheckpointCoordinator>>,
253 operations: Arc<tokio::sync::Mutex<Option<HashMap<String, OperationRecord>>>>,
254 ledger_path: PathBuf,
255}
256
257#[derive(Clone, Deserialize, Serialize)]
258struct OperationRecord {
259 fingerprint: Vec<u8>,
260 status: u16,
261 body: Value,
262}
263
264#[derive(Default, Deserialize, Serialize)]
265struct OperationLedger {
266 version: u16,
267 operations: HashMap<String, OperationRecord>,
268}
269
270pub fn node_router_with_admin(
271 runtime: Arc<NodeRuntime>,
272 recorder: RecorderFileStore,
273 admin: AdminConfig,
274) -> Result<Router, ConfigError> {
275 node_router_with_admin_and_tasks(runtime, recorder, admin).map(|(router, _)| router)
276}
277
278pub fn node_router_with_admin_and_tasks(
279 runtime: Arc<NodeRuntime>,
280 recorder: RecorderFileStore,
281 admin: AdminConfig,
282) -> Result<(Router, AdminTaskTracker), ConfigError> {
283 validate_admin_token(&runtime, &admin)?;
284 let (admin_router, tasks) = admin_router(runtime.clone(), recorder.clone(), None, admin);
285 Ok((
286 crate::node_router(runtime, recorder).merge(admin_router),
287 tasks,
288 ))
289}
290
291pub fn node_router_with_checkpoint_and_admin(
292 runtime: Arc<NodeRuntime>,
293 recorder: RecorderFileStore,
294 coordinator: Arc<CheckpointCoordinator>,
295 admin: AdminConfig,
296) -> Result<Router, ConfigError> {
297 node_router_with_checkpoint_and_admin_tasks(runtime, recorder, coordinator, admin)
298 .map(|(router, _)| router)
299}
300
301pub fn node_router_with_checkpoint_and_admin_tasks(
302 runtime: Arc<NodeRuntime>,
303 recorder: RecorderFileStore,
304 coordinator: Arc<CheckpointCoordinator>,
305 admin: AdminConfig,
306) -> Result<(Router, AdminTaskTracker), ConfigError> {
307 validate_admin_token(&runtime, &admin)?;
308 let (admin_router, tasks) = admin_router(
309 runtime.clone(),
310 recorder.clone(),
311 Some(coordinator.clone()),
312 admin,
313 );
314 Ok((
315 crate::node_router_with_checkpoint(runtime, recorder, coordinator).merge(admin_router),
316 tasks,
317 ))
318}
319
320fn validate_admin_token(runtime: &NodeRuntime, admin: &AdminConfig) -> Result<(), ConfigError> {
321 if runtime.config().client_token() == admin.token
322 || runtime
323 .config()
324 .peers()
325 .iter()
326 .any(|peer| peer.token() == admin.token)
327 {
328 return Err(ConfigError::AdminTokenConflictsWithRuntime);
329 }
330 Ok(())
331}
332
333fn admin_router(
334 runtime: Arc<NodeRuntime>,
335 recorder: RecorderFileStore,
336 coordinator: Option<Arc<CheckpointCoordinator>>,
337 admin: AdminConfig,
338) -> (Router, AdminTaskTracker) {
339 let ledger_path = runtime.config().data_dir().join("admin-operations-v1.json");
340 let operations = load_operations(&ledger_path)
341 .map(Some)
342 .unwrap_or_else(|error| {
343 eprintln!("admin operation ledger is unavailable: {error}");
344 None
345 });
346 let state = AdminRouteState {
347 runtime,
348 recorder,
349 coordinator,
350 operations: Arc::new(tokio::sync::Mutex::new(operations)),
351 ledger_path,
352 };
353 let tasks = AdminTaskTracker::new();
354 let router = Router::new()
355 .route(ADMIN_STATUS_PATH, get(handle_status))
356 .route(ADMIN_STOP_PATH, post(handle_stop))
357 .route(ADMIN_INSTALL_SUCCESSOR_PATH, post(handle_install_successor))
358 .route(ADMIN_ACTIVATE_PATH, post(handle_activate))
359 .route(ADMIN_COMPACT_PATH, post(handle_compact))
360 .route_layer(middleware::from_fn_with_state(
361 AdminGateState {
362 token: admin.token,
363 admission: Arc::new(tokio::sync::Semaphore::new(1)),
364 tasks: tasks.clone(),
365 },
366 admin_gate,
367 ))
368 .with_state(state);
369 (router, tasks)
370}
371
372async fn admin_gate(
373 State(state): State<AdminGateState>,
374 mut request: Request,
375 next: Next,
376) -> Response {
377 if !client_authenticated(request.headers(), &state.token) {
378 return admin_error(StatusCode::UNAUTHORIZED, AdminErrorCode::Unauthorized);
379 }
380 let task = match state.tasks.try_start() {
381 Some(task) => task,
382 None => return admin_error(StatusCode::SERVICE_UNAVAILABLE, AdminErrorCode::Unavailable),
383 };
384 let permit = match state.admission.try_acquire_owned() {
385 Ok(admission) => Arc::new(AdminPermit {
386 _admission: admission,
387 _task: task,
388 }),
389 Err(_) => return admin_error(StatusCode::TOO_MANY_REQUESTS, AdminErrorCode::Unavailable),
390 };
391 request.extensions_mut().insert(permit);
392 next.run(request).await
393}
394
395async fn handle_status(
396 State(state): State<AdminRouteState>,
397 Extension(_permit): Extension<Arc<AdminPermit>>,
398) -> Response {
399 let _operations = state.operations.lock().await;
400 match status_response(&state).await {
401 Ok(response) => Json(response).into_response(),
402 Err(error) => node_admin_error(error),
403 }
404}
405
406async fn handle_stop(
407 State(state): State<AdminRouteState>,
408 Extension(permit): Extension<Arc<AdminPermit>>,
409 payload: Result<Json<AdminStopRequest>, JsonRejection>,
410) -> Response {
411 let request = match payload {
412 Ok(Json(request)) => request,
413 Err(_) => return admin_error(StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest),
414 };
415 let runtime = state.runtime.clone();
416 let owned = request.clone();
417 run_async_operation(&state, permit, "stop", &request, async move {
418 tokio::task::spawn_blocking(move || {
419 let successor = validate_successor(
420 &owned.successor,
421 owned.expected_config_id,
422 runtime.config().cluster_id(),
423 )?;
424 runtime
425 .stop_current_configuration_for_successor(&successor)
426 .map(|stop| AdminStopResponse {
427 operation_id: owned.operation_id,
428 stop,
429 successor: owned.successor,
430 })
431 .map_err(OperationError::Node)
432 })
433 .await
434 .unwrap_or(Err(OperationError::Unavailable))
435 })
436 .await
437}
438
439async fn handle_install_successor(
440 State(state): State<AdminRouteState>,
441 Extension(permit): Extension<Arc<AdminPermit>>,
442 payload: Result<Json<AdminInstallSuccessorRequest>, JsonRejection>,
443) -> Response {
444 let request = match payload {
445 Ok(Json(request)) => request,
446 Err(_) => return admin_error(StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest),
447 };
448 let runtime = state.runtime.clone();
449 let recorder = state.recorder.clone();
450 let owned = request.clone();
451 run_async_operation(&state, permit, "install_successor", &request, async move {
452 tokio::task::spawn_blocking(move || {
453 install_successor(&runtime, &recorder, &owned).map_err(OperationError::Node)
454 })
455 .await
456 .unwrap_or(Err(OperationError::Unavailable))
457 })
458 .await
459}
460
461async fn handle_activate(
462 State(state): State<AdminRouteState>,
463 Extension(permit): Extension<Arc<AdminPermit>>,
464 payload: Result<Json<AdminActivateRequest>, JsonRejection>,
465) -> Response {
466 let request = match payload {
467 Ok(Json(request)) => request,
468 Err(_) => return admin_error(StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest),
469 };
470 let runtime = state.runtime.clone();
471 let owned = request.clone();
472 run_async_operation(&state, permit, "activate", &request, async move {
473 tokio::task::spawn_blocking(move || {
474 runtime
475 .activate_successor_if(owned.expected_config_id)
476 .map(|entry| AdminActivateResponse {
477 operation_id: owned.operation_id,
478 entry,
479 })
480 .map_err(OperationError::Node)
481 })
482 .await
483 .unwrap_or(Err(OperationError::Unavailable))
484 })
485 .await
486}
487
488async fn handle_compact(
489 State(state): State<AdminRouteState>,
490 Extension(permit): Extension<Arc<AdminPermit>>,
491 payload: Result<Json<AdminCompactRequest>, JsonRejection>,
492) -> Response {
493 let request = match payload {
494 Ok(Json(request)) => request,
495 Err(_) => return admin_error(StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest),
496 };
497 let runtime = state.runtime.clone();
498 let coordinator = state.coordinator.clone();
499 let owned = request.clone();
500 run_async_operation(&state, permit, "compact", &request, async move {
501 match coordinator {
502 Some(coordinator) => coordinator
503 .checkpoint_compact_fenced(
504 &runtime,
505 owned.expected_config_id,
506 owned.expected_recovery_generation,
507 owned.expected_root,
508 )
509 .await
510 .map(|anchor| AdminCompactResponse {
511 operation_id: owned.operation_id,
512 anchor,
513 })
514 .map_err(OperationError::Durability),
515 None => Err(OperationError::Unavailable),
516 }
517 })
518 .await
519}
520
521async fn run_async_operation<T, R, F>(
522 state: &AdminRouteState,
523 permit: Arc<AdminPermit>,
524 kind: &str,
525 request: &T,
526 operation: F,
527) -> Response
528where
529 T: Serialize,
530 R: Serialize + Send + 'static,
531 F: std::future::Future<Output = Result<R, OperationError>> + Send + 'static,
532{
533 let operation_id = match serde_json::to_value(request)
534 .ok()
535 .and_then(|value| value.get("operation_id")?.as_str().map(str::to_owned))
536 {
537 Some(operation_id) => operation_id,
538 None => return admin_error(StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest),
539 };
540 let fingerprint = match operation_fingerprint(kind, request) {
541 Ok(fingerprint) => fingerprint,
542 Err(()) => return admin_error(StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest),
543 };
544 {
545 let operations = state.operations.lock().await;
546 let Some(operations) = operations.as_ref() else {
547 return admin_error(StatusCode::SERVICE_UNAVAILABLE, AdminErrorCode::Unavailable);
548 };
549 if let Some(response) = replay(operations, &operation_id, &fingerprint) {
550 return response;
551 }
552 }
553 if let Some(response) = validate_operation_id(&operation_id) {
554 return response;
555 }
556 let detached_state = state.clone();
557 let detached_operation_id = operation_id.clone();
558 let detached_fingerprint = fingerprint.clone();
559 let (completed, mut completion) = tokio::sync::watch::channel(false);
560 tokio::spawn(async move {
561 let result = operation.await;
562 let mut operations = detached_state.operations.lock().await;
563 if let Some(records) = operations.as_mut() {
564 let _ = store_result(records, detached_operation_id, detached_fingerprint, result);
565 if let Err(error) = persist_operations(&detached_state.ledger_path, records) {
566 eprintln!("admin operation ledger persistence failed: {error}");
567 *operations = None;
568 }
569 }
570 drop(operations);
571 drop(detached_state);
572 drop(permit);
573 completed.send_replace(true);
574 });
575 let waited = tokio::time::timeout(std::time::Duration::from_secs(10), async {
576 while !*completion.borrow() {
577 if completion.changed().await.is_err() {
578 break;
579 }
580 }
581 })
582 .await;
583 if waited.is_err() {
584 return admin_error(StatusCode::SERVICE_UNAVAILABLE, AdminErrorCode::Unavailable);
585 }
586 let operations = state.operations.lock().await;
587 let Some(operations) = operations.as_ref() else {
588 return admin_error(StatusCode::SERVICE_UNAVAILABLE, AdminErrorCode::Unavailable);
589 };
590 replay(operations, &operation_id, &fingerprint)
591 .unwrap_or_else(|| admin_error(StatusCode::INTERNAL_SERVER_ERROR, AdminErrorCode::Internal))
592}
593
594fn operation_fingerprint(kind: &str, request: &impl Serialize) -> Result<Vec<u8>, ()> {
595 serde_json::to_vec(&(kind, request)).map_err(|_| ())
596}
597
598fn replay(
599 operations: &HashMap<String, OperationRecord>,
600 operation_id: &str,
601 fingerprint: &[u8],
602) -> Option<Response> {
603 operations.get(operation_id).map(|record| {
604 if record.fingerprint == fingerprint {
605 (
606 StatusCode::from_u16(record.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
607 Json(record.body.clone()),
608 )
609 .into_response()
610 } else {
611 admin_error(StatusCode::CONFLICT, AdminErrorCode::OperationConflict)
612 }
613 })
614}
615
616fn validate_operation_id(operation_id: &str) -> Option<Response> {
617 (operation_id.trim().is_empty() || operation_id.len() > 256)
618 .then(|| admin_error(StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest))
619}
620
621fn store_result<R: Serialize>(
622 operations: &mut HashMap<String, OperationRecord>,
623 operation_id: String,
624 fingerprint: Vec<u8>,
625 result: Result<R, OperationError>,
626) -> Response {
627 let (status, body) = match result {
628 Ok(response) => match serde_json::to_value(response) {
629 Ok(body) => (StatusCode::OK, body),
630 Err(_) => (
631 StatusCode::INTERNAL_SERVER_ERROR,
632 error_value(AdminErrorCode::Internal),
633 ),
634 },
635 Err(error) => operation_error_value(error),
636 };
637 operations.insert(
638 operation_id,
639 OperationRecord {
640 fingerprint,
641 status: status.as_u16(),
642 body: body.clone(),
643 },
644 );
645 (status, Json(body)).into_response()
646}
647
648async fn status_response(state: &AdminRouteState) -> Result<AdminStatusResponse, NodeError> {
649 let checkpoint_root = match state.coordinator.as_ref() {
650 Some(coordinator) => {
651 let tip = coordinator
652 .refresh_durable_tip()
653 .await
654 .map_err(|error| NodeError::Unavailable(error.to_string()))?;
655 Some(LogAnchor::new(tip.index(), tip.hash()))
656 }
657 None => None,
658 };
659 let _commit = state.runtime.lock_commit()?;
660 let node = state.runtime.status()?;
661 let qlog_root = state.runtime.log_root_unlocked()?;
662 let stopped_transition = stopped_transition(&state.runtime)?;
663 Ok(AdminStatusResponse {
664 cluster_id: state.runtime.config.cluster_id().to_owned(),
665 execution_profile: state.runtime.config.execution_profile(),
666 epoch: state.runtime.config.epoch(),
667 node,
668 members: state.runtime.config.membership().members().to_vec(),
669 recovery_generation: state.runtime.config.recovery_generation(),
670 qlog_root,
671 checkpoint_root,
672 stopped_transition,
673 })
674}
675
676fn stopped_transition(runtime: &NodeRuntime) -> Result<Option<AdminStoppedTransition>, NodeError> {
677 let configuration = runtime.configuration_state()?;
678 let Some(anchor) = configuration.stop().copied() else {
679 return Ok(None);
680 };
681 if configuration.config_id() != runtime.consensus.config_id() {
682 return Ok(None);
683 }
684 let entry = runtime.recover_stop_entry(anchor)?;
685 let successor = successor_from_entry(&entry)?;
686 let proof = runtime
687 .consensus
688 .inspect_decision_proof_at(entry.index)
689 .map_err(|error| NodeError::Unavailable(error.to_string()))?
690 .ok_or_else(|| NodeError::Unavailable("durable Stop proof is unavailable".into()))?;
691 Ok(Some(AdminStoppedTransition {
692 stop: StopInformation {
693 version: 2,
694 entry,
695 proof,
696 },
697 successor,
698 }))
699}
700
701fn successor_from_entry(entry: &LogEntry) -> Result<AdminSuccessorBundle, NodeError> {
702 let command = StoredCommand::new(entry.entry_type, entry.payload.clone());
703 let change = ConfigChange::recognize(&command)
704 .map_err(|_| NodeError::PreconditionFailed("legacy unbound Stop is rejected".into()))?;
705 let successor = change
706 .successor()
707 .ok_or_else(|| NodeError::PreconditionFailed("legacy unbound Stop is rejected".into()))?;
708 Ok(AdminSuccessorBundle {
709 config_id: successor.config_id(),
710 members: successor.members().to_vec(),
711 digest: successor.digest(),
712 })
713}
714
715fn validate_successor(
716 bundle: &AdminSuccessorBundle,
717 predecessor_config_id: u64,
718 cluster_id: &str,
719) -> Result<Membership, OperationError> {
720 let membership = Membership::from_voters(bundle.members.clone()).map_err(|_| {
721 OperationError::Node(NodeError::InvalidRequest(
722 "successor membership is invalid".into(),
723 ))
724 })?;
725 if predecessor_config_id.checked_add(1) != Some(bundle.config_id)
726 || membership.digest() != bundle.digest
727 || cluster_id.is_empty()
728 {
729 return Err(OperationError::Node(NodeError::PreconditionFailed(
730 "successor descriptor does not match the active configuration".into(),
731 )));
732 }
733 Ok(membership)
734}
735
736fn load_operations(path: &Path) -> Result<HashMap<String, OperationRecord>, std::io::Error> {
737 let bytes = match fs::read(path) {
738 Ok(bytes) => bytes,
739 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
740 Err(error) => return Err(error),
741 };
742 let ledger: OperationLedger = serde_json::from_slice(&bytes)
743 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
744 if ledger.version != 1 {
745 return Err(std::io::Error::new(
746 std::io::ErrorKind::InvalidData,
747 "unsupported admin operation ledger version",
748 ));
749 }
750 Ok(ledger.operations)
751}
752
753fn persist_operations(
754 path: &Path,
755 operations: &HashMap<String, OperationRecord>,
756) -> Result<(), std::io::Error> {
757 let parent = path.parent().ok_or_else(|| {
758 std::io::Error::new(std::io::ErrorKind::InvalidInput, "ledger has no parent")
759 })?;
760 fs::create_dir_all(parent)?;
761 let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
762 let bytes = serde_json::to_vec(&OperationLedger {
763 version: 1,
764 operations: operations.clone(),
765 })
766 .map_err(std::io::Error::other)?;
767 let mut file = fs::File::create(&temporary)?;
768 file.write_all(&bytes)?;
769 file.sync_all()?;
770 fs::rename(&temporary, path)?;
771 fs::File::open(parent)?.sync_all()
772}
773
774fn install_successor(
775 runtime: &NodeRuntime,
776 recorder: &RecorderFileStore,
777 request: &AdminInstallSuccessorRequest,
778) -> Result<AdminInstallSuccessorResponse, NodeError> {
779 let _commit = runtime.lock_commit()?;
780 runtime.ensure_ready()?;
781 let state = runtime.configuration_state()?;
782 if state.is_active()
783 || state.config_id() != request.expected_config_id
784 || state.stop().copied() != Some(request.expected_stopped_anchor)
785 {
786 return Err(NodeError::PreconditionFailed(
787 "stopped configuration anchor does not match".into(),
788 ));
789 }
790 let old_membership = Membership::from_voters(request.old_members.clone())
791 .map_err(|_| NodeError::InvalidRequest("old membership is invalid".into()))?;
792 if old_membership.digest() != state.digest()
793 || request.stop.entry.cluster_id != runtime.config.cluster_id()
794 || request.stop.entry.epoch != runtime.config.epoch()
795 || request.stop.entry.config_id != request.expected_config_id
796 || request.stop.entry.index != request.expected_stopped_anchor.index()
797 || request.stop.entry.hash != request.expected_stopped_anchor.hash()
798 {
799 return Err(NodeError::PreconditionFailed(
800 "old decision material does not match the stopped runtime".into(),
801 ));
802 }
803 let entries = runtime
804 .log_store
805 .read_range(
806 IndexRange::new(request.stop.entry.index, request.stop.entry.index)
807 .map_err(|error| NodeError::Storage(error.to_string()))?,
808 )
809 .map_err(|error| NodeError::Storage(error.to_string()))?;
810 if entries.as_slice() != [request.stop.entry.clone()] {
811 return Err(NodeError::PreconditionFailed(
812 "old stop entry is not the exact local qlog entry".into(),
813 ));
814 }
815 let successor = Membership::from_voters(request.successor.members.clone())
816 .map_err(|_| NodeError::InvalidRequest("successor membership is invalid".into()))?;
817 if successor.digest() != request.successor.digest
818 || request.expected_config_id.checked_add(1) != Some(request.successor.config_id)
819 || successor_from_entry(&request.stop.entry)? != request.successor
820 {
821 return Err(NodeError::PreconditionFailed(
822 "successor bundle or digest does not match".into(),
823 ));
824 }
825 let installed = install_successor_recorder(
826 recorder,
827 request.successor.config_id,
828 successor,
829 &request.stop,
830 )?;
831 Ok(AdminInstallSuccessorResponse {
832 operation_id: request.operation_id.clone(),
833 config_id: installed.config_id(),
834 digest: installed.config_digest(),
835 activated: installed.is_activated(),
836 })
837}
838
839enum OperationError {
840 Node(NodeError),
841 Durability(DurabilityError),
842 Unavailable,
843}
844
845fn operation_error_value(error: OperationError) -> (StatusCode, Value) {
846 match error {
847 OperationError::Node(error) => {
848 eprintln!("admin operation failed: {error}");
849 let (status, code) = node_admin_status(&error);
850 (status, error_value(code))
851 }
852 OperationError::Durability(DurabilityError::PreconditionFailed) => (
853 StatusCode::CONFLICT,
854 error_value(AdminErrorCode::PreconditionFailed),
855 ),
856 OperationError::Durability(error) => {
857 eprintln!("admin durability operation failed: {error}");
858 (
859 StatusCode::SERVICE_UNAVAILABLE,
860 error_value(AdminErrorCode::Unavailable),
861 )
862 }
863 OperationError::Unavailable => (
864 StatusCode::SERVICE_UNAVAILABLE,
865 error_value(AdminErrorCode::Unavailable),
866 ),
867 }
868}
869
870fn node_admin_error(error: NodeError) -> Response {
871 let (status, code) = node_admin_status(&error);
872 admin_error(status, code)
873}
874
875fn node_admin_status(error: &NodeError) -> (StatusCode, AdminErrorCode) {
876 match error {
877 NodeError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest),
878 #[cfg(feature = "sql")]
879 NodeError::InvalidSqlStatement { .. } => {
880 (StatusCode::BAD_REQUEST, AdminErrorCode::InvalidRequest)
881 }
882 NodeError::PreconditionFailed(_) | NodeError::ConfigurationTransition { .. } => {
883 (StatusCode::CONFLICT, AdminErrorCode::PreconditionFailed)
884 }
885 #[cfg(feature = "sql")]
886 NodeError::RequestConflict(_) => (StatusCode::CONFLICT, AdminErrorCode::PreconditionFailed),
887 NodeError::Unavailable(_)
888 | NodeError::ResourceExhausted(_)
889 | NodeError::Contention(_)
890 | NodeError::WinnerLimitExceeded => {
891 (StatusCode::SERVICE_UNAVAILABLE, AdminErrorCode::Unavailable)
892 }
893 NodeError::UnsupportedAckMode(_)
894 | NodeError::ExecutionProfileMismatch { .. }
895 | NodeError::DataRootLocked(_)
896 | NodeError::SnapshotRequired(_)
897 | NodeError::Storage(_)
898 | NodeError::Reconciliation(_)
899 | NodeError::Invariant(_)
900 | NodeError::Fatal(_) => (StatusCode::INTERNAL_SERVER_ERROR, AdminErrorCode::Internal),
901 }
902}
903
904fn admin_error(status: StatusCode, code: AdminErrorCode) -> Response {
905 (status, Json(AdminErrorResponse { code })).into_response()
906}
907
908fn error_value(code: AdminErrorCode) -> Value {
909 serde_json::to_value(AdminErrorResponse { code })
910 .unwrap_or_else(|_| serde_json::json!({"code": "internal"}))
911}
912
913#[cfg(test)]
914mod tests {
915 use std::sync::{
916 atomic::{AtomicUsize, Ordering},
917 Arc,
918 };
919
920 use super::AdminTaskTracker;
921
922 #[tokio::test]
923 async fn shutdown_observes_a_late_admin_mutation_before_sampling_state() {
924 let tasks = AdminTaskTracker::new();
925 let guard = tasks.try_start().unwrap();
926 let committed_tip = Arc::new(AtomicUsize::new(1));
927 let late_tip = Arc::clone(&committed_tip);
928 let (release, wait) = tokio::sync::oneshot::channel();
929 let operation = tokio::spawn(async move {
930 let _ = wait.await;
931 late_tip.store(2, Ordering::Release);
932 drop(guard);
933 });
934
935 tasks.stop_admission();
936 assert!(tasks.try_start().is_none());
937 let mut idle = Box::pin(tasks.wait_for_idle());
938 assert!(
939 tokio::time::timeout(std::time::Duration::from_millis(10), &mut idle)
940 .await
941 .is_err()
942 );
943
944 let _ = release.send(());
945 idle.await;
946 operation.await.unwrap();
947 assert_eq!(committed_tip.load(Ordering::Acquire), 2);
948 }
949
950 #[tokio::test]
951 async fn shutdown_waits_until_every_admitted_admin_task_finishes() {
952 let tasks = AdminTaskTracker::new();
953 let first = tasks.try_start().unwrap();
954 let second = tasks.try_start().unwrap();
955 tasks.stop_admission();
956
957 let mut idle = Box::pin(tasks.wait_for_idle());
958 drop(first);
959 assert!(
960 tokio::time::timeout(std::time::Duration::from_millis(10), &mut idle)
961 .await
962 .is_err()
963 );
964
965 drop(second);
966 tokio::time::timeout(std::time::Duration::from_secs(1), idle)
967 .await
968 .expect("last task completion must wake the shutdown waiter");
969 }
970}