Skip to main content

mongreldb_server/
cluster_runtime.rs

1//! Server-hosted [`NodeRuntime`] product path (Stage 2/3).
2//!
3//! When the operator enables cluster mode (`--cluster-node-data` /
4//! `MONGRELDB_CLUSTER_NODE_DATA`), the daemon loads the provisioned
5//! [`NodeIdentity`], starts a live [`NodeRuntime`], and wires admin SQL
6//! (TRANSFER LEADER / SPLIT TABLET / MERGE TABLETS) through it.
7//!
8//! Standalone mode (the default) never starts a runtime. Mutating cluster
9//! admin commands that need a live group fail closed with
10//! `"cluster runtime not running"` so operators are not misled by silent
11//! `"accepted"` stubs.
12//!
13//! # Trust / transport
14//!
15//! Production builds load mTLS material from the node's persisted
16//! `cluster-meta/trust.json`. For loopback integration tests only,
17//! `MONGRELDB_CLUSTER_PLAINTEXT_TEST=1` selects
18//! [`TransportSecurity::PlaintextForTesting`] (non-production escape hatch).
19
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use std::time::Duration;
23
24use mongreldb_cluster::bootstrap::{self, ClusterStatus, TrustConfig};
25use mongreldb_cluster::network::{TlsConfig, TransportConfig, TransportSecurity};
26use mongreldb_cluster::node::{ClusterError, NodeIdentity};
27use mongreldb_cluster::runtime::{
28    GroupTiming, MetaMembership, NodeInternalRpcClient, NodeRuntime, NodeRuntimeConfig,
29    RuntimeError, RuntimeStatus,
30};
31use mongreldb_cluster::tablet::Key;
32use mongreldb_log::commit_log::ExecutionControl;
33use mongreldb_types::ids::{NodeId, TabletId};
34use serde::{Deserialize, Serialize};
35use serde_json::{json, Value};
36use tokio::sync::Mutex;
37
38/// Operator / test configuration for starting a live cluster node runtime.
39#[derive(Clone, Debug)]
40pub struct ClusterRuntimeOptions {
41    /// Node data root (identity, `cluster-meta/`, tablets, meta group).
42    pub node_data: PathBuf,
43    /// RPC listen address (`host:port`; port `0` is resolved to a free port).
44    pub rpc_listen: String,
45    /// Use plaintext transport (test-only; non-production).
46    pub plaintext_test: bool,
47    /// Install fast raft election timers (tests).
48    pub fast_timing: bool,
49}
50
51impl ClusterRuntimeOptions {
52    /// Build options from an explicit node-data path and optional listen
53    /// address, consulting environment variables for the rest.
54    ///
55    /// - `MONGRELDB_CLUSTER_RPC_LISTEN` — default listen when `rpc_listen` is `None`
56    /// - `MONGRELDB_CLUSTER_PLAINTEXT_TEST=1` — plaintext transport + fast timing
57    pub fn resolve(node_data: PathBuf, rpc_listen: Option<String>) -> Self {
58        let rpc_listen = rpc_listen
59            .or_else(|| std::env::var("MONGRELDB_CLUSTER_RPC_LISTEN").ok())
60            .filter(|s| !s.trim().is_empty())
61            .unwrap_or_else(|| "127.0.0.1:17443".to_owned());
62        let plaintext_test = env_flag_true("MONGRELDB_CLUSTER_PLAINTEXT_TEST");
63        Self {
64            node_data,
65            rpc_listen,
66            plaintext_test,
67            // Fast timers only in the plaintext test escape hatch so
68            // production defaults stay conservative.
69            fast_timing: plaintext_test,
70        }
71    }
72}
73
74/// Shared handle to a live [`NodeRuntime`] stored on `AppState`.
75///
76/// The runtime lives behind a mutex so admin handlers can call async
77/// mut methods (`split_tablet` / `merge_tablets`). `shutdown` takes the
78/// runtime out once so graceful stop is single-shot even when the handle
79/// is cloned across the router and `ServerControl`.
80#[derive(Clone)]
81pub struct ClusterRuntimeHandle {
82    inner: Arc<Mutex<Option<NodeRuntime>>>,
83    node_data: PathBuf,
84}
85
86/// Failures starting or driving the server-hosted runtime.
87#[derive(Debug)]
88pub enum ClusterRuntimeError {
89    /// Bootstrap / identity layer.
90    Cluster(ClusterError),
91    /// Node runtime layer.
92    Runtime(RuntimeError),
93    /// Operator configuration (listen address, trust material, …).
94    Config(String),
95}
96
97impl std::fmt::Display for ClusterRuntimeError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::Cluster(error) => write!(f, "{error}"),
101            Self::Runtime(error) => write!(f, "{error}"),
102            Self::Config(message) => write!(f, "{message}"),
103        }
104    }
105}
106
107impl std::error::Error for ClusterRuntimeError {
108    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
109        match self {
110            Self::Cluster(error) => Some(error),
111            Self::Runtime(error) => Some(error),
112            Self::Config(_) => None,
113        }
114    }
115}
116
117impl From<ClusterError> for ClusterRuntimeError {
118    fn from(error: ClusterError) -> Self {
119        Self::Cluster(error)
120    }
121}
122
123impl From<RuntimeError> for ClusterRuntimeError {
124    fn from(error: RuntimeError) -> Self {
125        Self::Runtime(error)
126    }
127}
128
129impl From<std::io::Error> for ClusterRuntimeError {
130    fn from(error: std::io::Error) -> Self {
131        Self::Config(format!("cluster runtime I/O: {error}"))
132    }
133}
134
135impl ClusterRuntimeHandle {
136    /// Load identity + bootstrap state from `options.node_data`, start the
137    /// runtime, and wrap it. Fails closed when the node has not been
138    /// provisioned (`cluster init` / `cluster join`).
139    pub async fn start(options: ClusterRuntimeOptions) -> Result<Self, ClusterRuntimeError> {
140        let _identity =
141            NodeIdentity::load(&options.node_data)?.ok_or(ClusterError::NotInitialized)?;
142        let status = bootstrap::cluster_status(&options.node_data)?;
143        let listen = resolve_listen_address(&options.rpc_listen)?;
144        let security = resolve_security(&options.node_data, options.plaintext_test)?;
145        let peers = peers_from_status(&status, &listen);
146        let meta = meta_membership_from_status(&status, &listen);
147
148        let config = NodeRuntimeConfig {
149            node_data: options.node_data.clone(),
150            security,
151            transport: transport_config(options.plaintext_test),
152            listen_address: listen.clone(),
153            rpc_address: Some(listen),
154            peers,
155            meta,
156            timing: options.fast_timing.then(fast_timing),
157        };
158        let runtime = NodeRuntime::start(config).await?;
159        Ok(Self {
160            inner: Arc::new(Mutex::new(Some(runtime))),
161            node_data: options.node_data,
162        })
163    }
164
165    /// Node data directory this runtime was started from.
166    pub fn node_data(&self) -> &Path {
167        &self.node_data
168    }
169
170    /// Whether the runtime is still live (not yet shut down).
171    pub async fn is_live(&self) -> bool {
172        self.inner.lock().await.is_some()
173    }
174
175    /// JSON view of [`RuntimeStatus`] for admin status surfaces.
176    pub async fn runtime_status_json(&self) -> Result<Value, ClusterRuntimeError> {
177        let guard = self.inner.lock().await;
178        let runtime = guard
179            .as_ref()
180            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
181        Ok(runtime_status_to_json(&runtime.status()))
182    }
183
184    /// `TRANSFER LEADER <tablet> TO <node>` against a live tablet group.
185    pub async fn transfer_leader(
186        &self,
187        tablet_id: TabletId,
188        to: NodeId,
189    ) -> Result<Value, ClusterRuntimeError> {
190        let guard = self.inner.lock().await;
191        let runtime = guard
192            .as_ref()
193            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
194        let descriptor = runtime.tablet_descriptor(tablet_id).ok_or_else(|| {
195            ClusterRuntimeError::Config(format!(
196                "this node hosts no live tablet group for tablet {tablet_id}"
197            ))
198        })?;
199        let target = descriptor.replica_on(to).ok_or_else(|| {
200            ClusterRuntimeError::Config(format!("node {to} is not a replica of tablet {tablet_id}"))
201        })?;
202        let group = runtime.tablet_group(tablet_id).ok_or_else(|| {
203            ClusterRuntimeError::Config(format!(
204                "this node hosts no live tablet group for tablet {tablet_id}"
205            ))
206        })?;
207        group
208            .transfer_leader(target.raft_node_id, LEADER_TIMEOUT)
209            .await
210            .map_err(|error| ClusterRuntimeError::Config(error.to_string()))?;
211        Ok(json!({
212            "command": "TRANSFER LEADER",
213            "tablet_id": tablet_id.to_string(),
214            "to": to.to_string(),
215            "status": "ok",
216            "target_raft_node_id": target.raft_node_id,
217        }))
218    }
219
220    /// `SPLIT TABLET` against a live runtime (requires meta + hosted tablet).
221    pub async fn split_tablet(
222        &self,
223        tablet_id: TabletId,
224        at_key_hex: Option<String>,
225    ) -> Result<Value, ClusterRuntimeError> {
226        let split_key = match at_key_hex.as_deref() {
227            Some(hex) => Some(parse_key_hex(hex)?),
228            None => None,
229        };
230        let mut guard = self.inner.lock().await;
231        let runtime = guard
232            .as_mut()
233            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
234        if runtime.tablet_group(tablet_id).is_none() {
235            return Err(ClusterRuntimeError::Config(format!(
236                "this node hosts no live tablet group for tablet {tablet_id}"
237            )));
238        }
239        let control = ExecutionControl::default();
240        let published = runtime.split_tablet(tablet_id, split_key, &control).await?;
241        Ok(json!({
242            "command": "SPLIT TABLET",
243            "tablet_id": tablet_id.to_string(),
244            "at_key_hex": at_key_hex,
245            "status": "ok",
246            "published": true,
247            "left_tablet_id": published.children[0].tablet_id.to_string(),
248            "right_tablet_id": published.children[1].tablet_id.to_string(),
249        }))
250    }
251
252    /// `MERGE TABLETS` against a live runtime (requires meta + hosted pair).
253    pub async fn merge_tablets(
254        &self,
255        left: TabletId,
256        right: TabletId,
257    ) -> Result<Value, ClusterRuntimeError> {
258        let mut guard = self.inner.lock().await;
259        let runtime = guard
260            .as_mut()
261            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
262        for tablet_id in [left, right] {
263            if runtime.tablet_group(tablet_id).is_none() {
264                return Err(ClusterRuntimeError::Config(format!(
265                    "this node hosts no live tablet group for tablet {tablet_id}"
266                )));
267            }
268        }
269        let control = ExecutionControl::default();
270        let published = runtime.merge_tablets(left, right, &control).await?;
271        Ok(json!({
272            "command": "MERGE TABLETS",
273            "left": left.to_string(),
274            "right": right.to_string(),
275            "status": "ok",
276            "published": true,
277            "replacement_tablet_id": published.replacement.tablet_id.to_string(),
278        }))
279    }
280
281    /// Direct access for tests that need to seed tablets onto the live runtime.
282    pub fn runtime_mutex(&self) -> Arc<Mutex<Option<NodeRuntime>>> {
283        Arc::clone(&self.inner)
284    }
285
286    /// Installs one authenticated node-internal RPC service.
287    pub async fn attach_internal_rpc_handler(
288        &self,
289        service_id: u32,
290        handler: Arc<dyn mongreldb_cluster::network::InternalRpcHandler>,
291    ) -> Result<(), ClusterRuntimeError> {
292        let guard = self.inner.lock().await;
293        let runtime = guard
294            .as_ref()
295            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
296        runtime.attach_internal_rpc_handler(service_id, handler);
297        Ok(())
298    }
299
300    /// Gets a cloneable client for authenticated internal fan-out.
301    pub async fn internal_rpc_client(&self) -> Result<NodeInternalRpcClient, ClusterRuntimeError> {
302        let guard = self.inner.lock().await;
303        let runtime = guard
304            .as_ref()
305            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
306        Ok(runtime.internal_rpc_client())
307    }
308
309    /// Graceful shutdown: stop the runtime once. Additional calls are no-ops.
310    pub async fn shutdown(&self) -> Result<(), ClusterRuntimeError> {
311        let runtime = {
312            let mut guard = self.inner.lock().await;
313            guard.take()
314        };
315        if let Some(runtime) = runtime {
316            runtime.shutdown().await?;
317        }
318        Ok(())
319    }
320}
321
322const LEADER_TIMEOUT: Duration = Duration::from_secs(15);
323
324fn env_flag_true(name: &str) -> bool {
325    match std::env::var(name) {
326        Ok(value) => {
327            let value = value.trim();
328            value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
329        }
330        Err(_) => false,
331    }
332}
333
334/// Bind-and-release when the operator requested port 0 so peer tables carry a
335/// concrete address before the runtime starts.
336fn resolve_listen_address(listen: &str) -> Result<String, ClusterRuntimeError> {
337    let trimmed = listen.trim();
338    if trimmed.is_empty() {
339        return Err(ClusterRuntimeError::Config(
340            "cluster RPC listen address is empty".into(),
341        ));
342    }
343    // Port 0 (or host:0) — allocate a free port up front.
344    if trimmed.ends_with(":0") {
345        let listener = std::net::TcpListener::bind(trimmed)?;
346        return Ok(listener.local_addr()?.to_string());
347    }
348    Ok(trimmed.to_owned())
349}
350
351fn resolve_security(
352    node_data: &Path,
353    plaintext_test: bool,
354) -> Result<TransportSecurity, ClusterRuntimeError> {
355    if plaintext_test {
356        return Ok(TransportSecurity::PlaintextForTesting);
357    }
358    let trust = load_persisted_trust(node_data)?.ok_or_else(|| {
359        ClusterRuntimeError::Config(
360            "cluster trust material missing under cluster-meta/trust.json; \
361             run `mongreldb-server cluster init` first, or set \
362             MONGRELDB_CLUSTER_PLAINTEXT_TEST=1 for non-production tests"
363                .into(),
364        )
365    })?;
366    let tls = TlsConfig::from_trust(&trust).map_err(|error| {
367        ClusterRuntimeError::Config(format!(
368            "cluster trust PEMs are not usable for mTLS ({error}); \
369             supply real certificates or set MONGRELDB_CLUSTER_PLAINTEXT_TEST=1 \
370             for non-production tests"
371        ))
372    })?;
373    Ok(TransportSecurity::Mtls(tls))
374}
375
376/// On-disk envelope written by `bootstrap::write_trust` (private there).
377#[derive(Deserialize, Serialize)]
378#[serde(deny_unknown_fields)]
379struct TrustEnvelope {
380    format_version: u32,
381    trust: TrustConfig,
382}
383
384fn load_persisted_trust(node_data: &Path) -> Result<Option<TrustConfig>, ClusterRuntimeError> {
385    let path = node_data.join("cluster-meta").join("trust.json");
386    if !path.exists() {
387        return Ok(None);
388    }
389    let bytes = std::fs::read(&path)?;
390    let envelope: TrustEnvelope = serde_json::from_slice(&bytes).map_err(|error| {
391        ClusterRuntimeError::Config(format!("cluster-meta/trust.json is corrupt: {error}"))
392    })?;
393    if envelope.format_version != 1 {
394        return Err(ClusterRuntimeError::Config(format!(
395            "cluster-meta/trust.json format version {} is unsupported",
396            envelope.format_version
397        )));
398    }
399    envelope
400        .trust
401        .validate()
402        .map_err(ClusterRuntimeError::from)?;
403    Ok(Some(envelope.trust))
404}
405
406fn peers_from_status(status: &ClusterStatus, self_listen: &str) -> Vec<(NodeId, String)> {
407    if !status.membership.is_empty() {
408        return status
409            .membership
410            .iter()
411            .map(|member| {
412                let address = if member.node_id == status.identity.node_id {
413                    self_listen.to_owned()
414                } else {
415                    member.rpc_address.clone()
416                };
417                (member.node_id, address)
418            })
419            .collect();
420    }
421    // Join-only or partial bootstrap: at least advertise this node.
422    vec![(status.identity.node_id, self_listen.to_owned())]
423}
424
425/// First product path: this node hosts meta when it is a database-group
426/// voter. Sole-voter init bootstraps the pristine meta group; multi-voter
427/// members reopen without re-bootstrap.
428fn meta_membership_from_status(
429    status: &ClusterStatus,
430    self_listen: &str,
431) -> Option<MetaMembership> {
432    let group = status.database_group.as_ref()?;
433    if !group.voter_ids.contains(&status.identity.node_id) {
434        return None;
435    }
436    let bootstrap_voters = if group.voter_ids.len() == 1 {
437        Some(vec![(status.identity.node_id, self_listen.to_owned())])
438    } else {
439        None
440    };
441    Some(MetaMembership {
442        meta_group_id: group.raft_group_id,
443        bootstrap_voters,
444    })
445}
446
447fn transport_config(plaintext_test: bool) -> TransportConfig {
448    if plaintext_test {
449        TransportConfig {
450            connect_timeout: Duration::from_millis(500),
451            rpc_timeout: Duration::from_secs(2),
452            snapshot_timeout: Duration::from_secs(5),
453            connect_attempts: 5,
454            reconnect_backoff: Duration::from_millis(10),
455            max_frame_bytes: 16 * 1024 * 1024,
456            max_connections: 64,
457            handshake_timeout: Duration::from_secs(2),
458            shutdown_grace: Duration::from_secs(2),
459        }
460    } else {
461        TransportConfig::default()
462    }
463}
464
465fn fast_timing() -> GroupTiming {
466    GroupTiming {
467        heartbeat_interval: Duration::from_millis(100),
468        election_timeout_min: Duration::from_millis(300),
469        election_timeout_max: Duration::from_millis(600),
470        install_snapshot_timeout: Duration::from_millis(2_000),
471    }
472}
473
474fn parse_key_hex(text: &str) -> Result<Key, ClusterRuntimeError> {
475    let trimmed = text.trim();
476    if trimmed.is_empty() {
477        return Err(ClusterRuntimeError::Config("split key hex is empty".into()));
478    }
479    if !trimmed.len().is_multiple_of(2) {
480        return Err(ClusterRuntimeError::Config(
481            "split key hex must have an even number of digits".into(),
482        ));
483    }
484    let mut bytes = Vec::with_capacity(trimmed.len() / 2);
485    let chars: Vec<char> = trimmed.chars().collect();
486    for chunk in chars.chunks(2) {
487        let hi = chunk[0].to_digit(16).ok_or_else(|| {
488            ClusterRuntimeError::Config(format!("invalid split key hex `{text}`"))
489        })?;
490        let lo = chunk[1].to_digit(16).ok_or_else(|| {
491            ClusterRuntimeError::Config(format!("invalid split key hex `{text}`"))
492        })?;
493        bytes.push(((hi << 4) | lo) as u8);
494    }
495    Ok(Key::from_bytes(bytes))
496}
497
498fn runtime_status_to_json(status: &RuntimeStatus) -> Value {
499    json!({
500        "live": true,
501        "node_id": status.identity.node_id.to_string(),
502        "cluster_id": status.identity.cluster_id.to_string(),
503        "rpc_address": status.rpc_address,
504        "meta_present": status.meta.is_some(),
505        "meta": status.meta.as_ref().map(|meta| json!({
506            "meta_group_id": meta.meta_group_id.to_string(),
507            "metadata_version": meta.metadata_version.get(),
508            "current_leader": meta.metrics.current_leader,
509            "local_raft_node_id": meta.metrics.node_id,
510        })),
511        "tablet_count": status.tablets.len(),
512        "tablets": status.tablets.iter().map(|tablet| json!({
513            "tablet_id": tablet.tablet_id.to_string(),
514            "raft_group_id": tablet.raft_group_id.to_string(),
515            "state": tablet.state.to_string(),
516            "replica_count": tablet.replicas.len(),
517            "applied_index": tablet.applied.index,
518            "current_leader": tablet.metrics.current_leader,
519            "local_raft_node_id": tablet.metrics.node_id,
520        })).collect::<Vec<_>>(),
521    })
522}
523
524/// Resolve cluster node-data from CLI / environment. `None` means standalone.
525pub fn cluster_node_data_from_env(cli_value: Option<String>) -> Option<PathBuf> {
526    cli_value
527        .or_else(|| std::env::var("MONGRELDB_CLUSTER_NODE_DATA").ok())
528        .map(|s| s.trim().to_owned())
529        .filter(|s| !s.is_empty())
530        .map(PathBuf::from)
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use mongreldb_cluster::bootstrap::{cluster_init, InitRequest};
537    use mongreldb_cluster::node::{Locality, NodeCapacity};
538    use tempfile::tempdir;
539
540    const CA_PEM: &str = "-----BEGIN CERTIFICATE-----\nY2E=\n-----END CERTIFICATE-----\n";
541    const CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\nbm9kZQ==\n-----END CERTIFICATE-----\n";
542    const KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nc2VjcmV0\n-----END PRIVATE KEY-----\n";
543
544    fn bootstrap(data: &Path) -> NodeIdentity {
545        let mut counter = 0u64;
546        let mut csprng = |buf: &mut [u8]| {
547            for chunk in buf.chunks_mut(8) {
548                counter += 1;
549                let bytes = counter.to_le_bytes();
550                chunk.copy_from_slice(&bytes[..chunk.len()]);
551            }
552            Ok(())
553        };
554        let identity = NodeIdentity::load_or_create(data, &mut csprng).unwrap();
555        let request = InitRequest {
556            rpc_address: "127.0.0.1:0".to_owned(),
557            locality: Locality::default(),
558            capacity: NodeCapacity::default(),
559            trust: TrustConfig::from_pems(
560                CA_PEM.to_owned(),
561                CERT_PEM.to_owned(),
562                KEY_PEM.to_owned(),
563                vec![identity.node_id],
564            )
565            .unwrap(),
566        };
567        cluster_init(data, &request, &mut csprng).unwrap().identity
568    }
569
570    #[tokio::test]
571    async fn plaintext_start_status_and_missing_tablet_errors() {
572        let dir = tempdir().unwrap();
573        let identity = bootstrap(dir.path());
574        let listen = {
575            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
576            listener.local_addr().unwrap().to_string()
577        };
578        let handle = ClusterRuntimeHandle::start(ClusterRuntimeOptions {
579            node_data: dir.path().to_path_buf(),
580            rpc_listen: listen.clone(),
581            plaintext_test: true,
582            fast_timing: true,
583        })
584        .await
585        .expect("runtime starts after cluster init");
586
587        assert!(handle.is_live().await);
588        let status = handle.runtime_status_json().await.unwrap();
589        assert_eq!(status["live"], true);
590        assert_eq!(status["node_id"], identity.node_id.to_string());
591        assert_eq!(status["rpc_address"], listen);
592        assert_eq!(status["meta_present"], true);
593        assert_eq!(status["tablet_count"], 0);
594
595        let missing = TabletId::from_bytes([0xAB; 16]);
596        let err = handle
597            .transfer_leader(missing, identity.node_id)
598            .await
599            .unwrap_err();
600        assert!(
601            err.to_string().contains("hosts no live tablet"),
602            "transfer without tablet must fail closed: {err}"
603        );
604        let err = handle.split_tablet(missing, None).await.unwrap_err();
605        assert!(
606            err.to_string().contains("hosts no live tablet"),
607            "split without tablet must fail closed: {err}"
608        );
609
610        handle.shutdown().await.unwrap();
611        assert!(!handle.is_live().await);
612    }
613
614    #[tokio::test]
615    async fn start_fails_closed_without_cluster_init() {
616        let dir = tempdir().unwrap();
617        match ClusterRuntimeHandle::start(ClusterRuntimeOptions {
618            node_data: dir.path().to_path_buf(),
619            rpc_listen: "127.0.0.1:0".into(),
620            plaintext_test: true,
621            fast_timing: true,
622        })
623        .await
624        {
625            Ok(_) => panic!("expected NotInitialized without cluster init"),
626            Err(err) => assert!(
627                matches!(
628                    err,
629                    ClusterRuntimeError::Cluster(ClusterError::NotInitialized)
630                ) || err
631                    .to_string()
632                    .to_ascii_lowercase()
633                    .contains("not initialized")
634                    || err.to_string().contains("NotInitialized"),
635                "expected NotInitialized, got {err}"
636            ),
637        }
638    }
639
640    #[test]
641    fn parse_key_hex_round_trip() {
642        let key = parse_key_hex("0a1b").unwrap();
643        assert_eq!(key.as_bytes(), &[0x0a, 0x1b]);
644        assert!(parse_key_hex("zz").is_err());
645        assert!(parse_key_hex("abc").is_err());
646    }
647}