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` and construct the runtime with
17//! [`NodeRuntimeConfig`]'s mTLS security mode. Plaintext cluster transport is
18//! refused on the production path. Under `cfg(test)` (and the
19//! `dangerous-test-transport` feature on `mongreldb-cluster`),
20//! `MONGRELDB_CLUSTER_PLAINTEXT_TEST=1` / `plaintext_test: true` remains a
21//! non-production escape hatch for loopback tests.
22
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::time::Duration;
26
27use mongreldb_cluster::bootstrap::{self, ClusterStatus, TrustConfig};
28use mongreldb_cluster::network::{TlsConfig, TransportConfig, TransportSecurity};
29use mongreldb_cluster::node::{ClusterError, NodeIdentity};
30use mongreldb_cluster::runtime::{
31    GroupTiming, MetaMembership, NodeInternalRpcClient, NodeRuntime, NodeRuntimeConfig,
32    RuntimeError, RuntimeStatus,
33};
34use mongreldb_cluster::tablet::Key;
35use mongreldb_log::commit_log::ExecutionControl;
36use mongreldb_types::ids::{NodeId, TabletId};
37use serde::{Deserialize, Serialize};
38use serde_json::{json, Value};
39use tokio::sync::Mutex;
40
41/// Operator / test configuration for starting a live cluster node runtime.
42#[derive(Clone, Debug)]
43pub struct ClusterRuntimeOptions {
44    /// Node data root (identity, `cluster-meta/`, tablets, meta group).
45    pub node_data: PathBuf,
46    /// RPC listen address (`host:port`; port `0` is resolved to a free port).
47    pub rpc_listen: String,
48    /// Use plaintext transport (test-only; non-production).
49    pub plaintext_test: bool,
50    /// Install fast raft election timers (tests).
51    pub fast_timing: bool,
52}
53
54impl ClusterRuntimeOptions {
55    /// Build options from an explicit node-data path and optional listen
56    /// address, consulting environment variables for the rest.
57    ///
58    /// - `MONGRELDB_CLUSTER_RPC_LISTEN` — default listen when `rpc_listen` is `None`
59    /// - `MONGRELDB_CLUSTER_PLAINTEXT_TEST=1` — plaintext transport + fast timing
60    pub fn resolve(node_data: PathBuf, rpc_listen: Option<String>) -> Self {
61        let rpc_listen = rpc_listen
62            .or_else(|| std::env::var("MONGRELDB_CLUSTER_RPC_LISTEN").ok())
63            .filter(|s| !s.trim().is_empty())
64            .unwrap_or_else(|| "127.0.0.1:17443".to_owned());
65        let plaintext_test = env_flag_true("MONGRELDB_CLUSTER_PLAINTEXT_TEST");
66        Self {
67            node_data,
68            rpc_listen,
69            plaintext_test,
70            // Fast timers only in the plaintext test escape hatch so
71            // production defaults stay conservative.
72            fast_timing: plaintext_test,
73        }
74    }
75}
76
77/// Shared handle to a live [`NodeRuntime`] stored on `AppState`.
78///
79/// The runtime lives behind a mutex so admin handlers can call async
80/// mut methods (`split_tablet` / `merge_tablets`). `shutdown` takes the
81/// runtime out once so graceful stop is single-shot even when the handle
82/// is cloned across the router and `ServerControl`.
83#[derive(Clone)]
84pub struct ClusterRuntimeHandle {
85    inner: Arc<Mutex<Option<NodeRuntime>>>,
86    node_data: PathBuf,
87}
88
89/// Failures starting or driving the server-hosted runtime.
90#[derive(Debug)]
91pub enum ClusterRuntimeError {
92    /// Bootstrap / identity layer.
93    Cluster(ClusterError),
94    /// Node runtime layer.
95    Runtime(RuntimeError),
96    /// Operator configuration (listen address, trust material, …).
97    Config(String),
98}
99
100impl std::fmt::Display for ClusterRuntimeError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::Cluster(error) => write!(f, "{error}"),
104            Self::Runtime(error) => write!(f, "{error}"),
105            Self::Config(message) => write!(f, "{message}"),
106        }
107    }
108}
109
110impl std::error::Error for ClusterRuntimeError {
111    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
112        match self {
113            Self::Cluster(error) => Some(error),
114            Self::Runtime(error) => Some(error),
115            Self::Config(_) => None,
116        }
117    }
118}
119
120impl From<ClusterError> for ClusterRuntimeError {
121    fn from(error: ClusterError) -> Self {
122        Self::Cluster(error)
123    }
124}
125
126impl From<RuntimeError> for ClusterRuntimeError {
127    fn from(error: RuntimeError) -> Self {
128        Self::Runtime(error)
129    }
130}
131
132impl From<std::io::Error> for ClusterRuntimeError {
133    fn from(error: std::io::Error) -> Self {
134        Self::Config(format!("cluster runtime I/O: {error}"))
135    }
136}
137
138impl ClusterRuntimeHandle {
139    /// Load identity + bootstrap state from `options.node_data`, start the
140    /// runtime, and wrap it. Fails closed when the node has not been
141    /// provisioned (`cluster init` / `cluster join`).
142    pub async fn start(options: ClusterRuntimeOptions) -> Result<Self, ClusterRuntimeError> {
143        let _identity =
144            NodeIdentity::load(&options.node_data)?.ok_or(ClusterError::NotInitialized)?;
145        let status = bootstrap::cluster_status(&options.node_data)?;
146        let listen = resolve_listen_address(&options.rpc_listen)?;
147        let security = resolve_security(&options.node_data, options.plaintext_test)?;
148        let peers = peers_from_status(&status, &listen);
149        let meta = meta_membership_from_status(&status, &listen);
150
151        let config = NodeRuntimeConfig {
152            node_data: options.node_data.clone(),
153            security,
154            transport: transport_config(options.plaintext_test),
155            listen_address: listen.clone(),
156            rpc_address: Some(listen),
157            peers,
158            meta,
159            timing: options.fast_timing.then(fast_timing),
160        };
161        let runtime = NodeRuntime::start(config).await?;
162        Ok(Self {
163            inner: Arc::new(Mutex::new(Some(runtime))),
164            node_data: options.node_data,
165        })
166    }
167
168    /// Node data directory this runtime was started from.
169    pub fn node_data(&self) -> &Path {
170        &self.node_data
171    }
172
173    /// Whether the runtime is still live (not yet shut down).
174    pub async fn is_live(&self) -> bool {
175        self.inner.lock().await.is_some()
176    }
177
178    /// JSON view of [`RuntimeStatus`] for admin status surfaces.
179    pub async fn runtime_status_json(&self) -> Result<Value, ClusterRuntimeError> {
180        let guard = self.inner.lock().await;
181        let runtime = guard
182            .as_ref()
183            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
184        Ok(runtime_status_to_json(&runtime.status()))
185    }
186
187    /// `TRANSFER LEADER <tablet> TO <node>` against a live tablet group.
188    pub async fn transfer_leader(
189        &self,
190        tablet_id: TabletId,
191        to: NodeId,
192    ) -> Result<Value, ClusterRuntimeError> {
193        let guard = self.inner.lock().await;
194        let runtime = guard
195            .as_ref()
196            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
197        let descriptor = runtime.tablet_descriptor(tablet_id).ok_or_else(|| {
198            ClusterRuntimeError::Config(format!(
199                "this node hosts no live tablet group for tablet {tablet_id}"
200            ))
201        })?;
202        let target = descriptor.replica_on(to).ok_or_else(|| {
203            ClusterRuntimeError::Config(format!("node {to} is not a replica of tablet {tablet_id}"))
204        })?;
205        let group = runtime.tablet_group(tablet_id).ok_or_else(|| {
206            ClusterRuntimeError::Config(format!(
207                "this node hosts no live tablet group for tablet {tablet_id}"
208            ))
209        })?;
210        group
211            .transfer_leader(target.raft_node_id, LEADER_TIMEOUT)
212            .await
213            .map_err(|error| ClusterRuntimeError::Config(error.to_string()))?;
214        Ok(json!({
215            "command": "TRANSFER LEADER",
216            "tablet_id": tablet_id.to_string(),
217            "to": to.to_string(),
218            "status": "ok",
219            "target_raft_node_id": target.raft_node_id,
220        }))
221    }
222
223    /// `SPLIT TABLET` against a live runtime (requires meta + hosted tablet).
224    pub async fn split_tablet(
225        &self,
226        tablet_id: TabletId,
227        at_key_hex: Option<String>,
228    ) -> Result<Value, ClusterRuntimeError> {
229        let split_key = match at_key_hex.as_deref() {
230            Some(hex) => Some(parse_key_hex(hex)?),
231            None => None,
232        };
233        let mut guard = self.inner.lock().await;
234        let runtime = guard
235            .as_mut()
236            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
237        if runtime.tablet_group(tablet_id).is_none() {
238            return Err(ClusterRuntimeError::Config(format!(
239                "this node hosts no live tablet group for tablet {tablet_id}"
240            )));
241        }
242        let control = ExecutionControl::default();
243        let published = runtime.split_tablet(tablet_id, split_key, &control).await?;
244        Ok(json!({
245            "command": "SPLIT TABLET",
246            "tablet_id": tablet_id.to_string(),
247            "at_key_hex": at_key_hex,
248            "status": "ok",
249            "published": true,
250            "left_tablet_id": published.children[0].tablet_id.to_string(),
251            "right_tablet_id": published.children[1].tablet_id.to_string(),
252        }))
253    }
254
255    /// `MERGE TABLETS` against a live runtime (requires meta + hosted pair).
256    pub async fn merge_tablets(
257        &self,
258        left: TabletId,
259        right: TabletId,
260    ) -> Result<Value, ClusterRuntimeError> {
261        let mut guard = self.inner.lock().await;
262        let runtime = guard
263            .as_mut()
264            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
265        for tablet_id in [left, right] {
266            if runtime.tablet_group(tablet_id).is_none() {
267                return Err(ClusterRuntimeError::Config(format!(
268                    "this node hosts no live tablet group for tablet {tablet_id}"
269                )));
270            }
271        }
272        let control = ExecutionControl::default();
273        let published = runtime.merge_tablets(left, right, &control).await?;
274        Ok(json!({
275            "command": "MERGE TABLETS",
276            "left": left.to_string(),
277            "right": right.to_string(),
278            "status": "ok",
279            "published": true,
280            "replacement_tablet_id": published.replacement.tablet_id.to_string(),
281        }))
282    }
283
284    /// Direct access for tests that need to seed tablets onto the live runtime.
285    pub fn runtime_mutex(&self) -> Arc<Mutex<Option<NodeRuntime>>> {
286        Arc::clone(&self.inner)
287    }
288
289    /// Installs one authenticated node-internal RPC service.
290    pub async fn attach_internal_rpc_handler(
291        &self,
292        service_id: u32,
293        handler: Arc<dyn mongreldb_cluster::network::InternalRpcHandler>,
294    ) -> Result<(), ClusterRuntimeError> {
295        let guard = self.inner.lock().await;
296        let runtime = guard
297            .as_ref()
298            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
299        runtime.attach_internal_rpc_handler(service_id, handler);
300        Ok(())
301    }
302
303    /// Gets a cloneable client for authenticated internal fan-out.
304    pub async fn internal_rpc_client(&self) -> Result<NodeInternalRpcClient, ClusterRuntimeError> {
305        let guard = self.inner.lock().await;
306        let runtime = guard
307            .as_ref()
308            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
309        Ok(runtime.internal_rpc_client())
310    }
311
312    /// Resolve the applied engine core for one locally hosted tablet.
313    ///
314    /// Uses `try_lock` so fragment/AI workers never block the async runtime
315    /// mutex; contention fails closed with `None` (caller retries / errors).
316    pub fn tablet_database_try(
317        &self,
318        tablet_id: TabletId,
319    ) -> Option<std::sync::Arc<mongreldb_core::Database>> {
320        let guard = self.inner.try_lock().ok()?;
321        let runtime = guard.as_ref()?;
322        let sink = runtime.tablet_sink(tablet_id)?;
323        let locked = sink.lock().ok()?;
324        locked.database()
325    }
326
327    /// Hosted tablet ids on this node (tablet-id order), for public data routing.
328    pub async fn tablet_ids(&self) -> Result<Vec<TabletId>, ClusterRuntimeError> {
329        let guard = self.inner.lock().await;
330        let runtime = guard
331            .as_ref()
332            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
333        Ok(runtime.tablet_ids())
334    }
335
336    /// Current applied opaque tablet rows (local replica view) for a hosted tablet.
337    pub async fn tablet_rows(
338        &self,
339        tablet_id: TabletId,
340    ) -> Result<std::collections::BTreeMap<Key, Vec<u8>>, ClusterRuntimeError> {
341        let guard = self.inner.lock().await;
342        let runtime = guard
343            .as_ref()
344            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
345        Ok(runtime.tablet_rows(tablet_id)?)
346    }
347
348    /// Typed user-table rows of a bound hosted tablet.
349    pub async fn tablet_typed_rows(
350        &self,
351        tablet_id: TabletId,
352    ) -> Result<mongreldb_consensus::engine_sink::TypedTabletRows, ClusterRuntimeError> {
353        let guard = self.inner.lock().await;
354        let runtime = guard
355            .as_ref()
356            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
357        Ok(runtime.tablet_typed_rows(tablet_id)?)
358    }
359
360    /// Bind a hosted tablet to a typed user-table schema (P0.3).
361    pub async fn bind_tablet_user_table(
362        &self,
363        tablet_id: TabletId,
364        binding: mongreldb_consensus::engine_sink::TabletTableBinding,
365    ) -> Result<mongreldb_consensus::engine_sink::TabletTableBinding, ClusterRuntimeError> {
366        let guard = self.inner.lock().await;
367        let runtime = guard
368            .as_ref()
369            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
370        Ok(runtime.bind_tablet_user_table(tablet_id, binding)?)
371    }
372
373    /// Current typed binding for a hosted tablet, if any.
374    pub async fn tablet_table_binding(
375        &self,
376        tablet_id: TabletId,
377    ) -> Result<Option<mongreldb_consensus::engine_sink::TabletTableBinding>, ClusterRuntimeError>
378    {
379        let guard = self.inner.lock().await;
380        let runtime = guard
381            .as_ref()
382            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
383        Ok(runtime.tablet_table_binding(tablet_id))
384    }
385
386    /// Raft-propose upserts into a hosted tablet's opaque MVCC keyspace.
387    ///
388    /// The local replica must be the group leader; [`ConsensusError::NotLeader`]
389    /// surfaces through [`ClusterRuntimeError::Runtime`] with a leader hint.
390    pub async fn write_tablet_rows(
391        &self,
392        tablet_id: TabletId,
393        entries: &[(Key, Vec<u8>)],
394    ) -> Result<mongreldb_consensus::group::GroupCommitReceipt, ClusterRuntimeError> {
395        let guard = self.inner.lock().await;
396        let runtime = guard
397            .as_ref()
398            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
399        let control = ExecutionControl::default();
400        Ok(runtime
401            .write_tablet_rows(tablet_id, entries, &control)
402            .await?)
403    }
404
405    /// Raft-propose typed user-table mutations (`COMMAND_TYPE_TABLET_WRITE`).
406    pub async fn write_tablet_ops(
407        &self,
408        tablet_id: TabletId,
409        operations: Vec<mongreldb_consensus::engine_sink::TabletWriteOperation>,
410    ) -> Result<mongreldb_consensus::group::GroupCommitReceipt, ClusterRuntimeError> {
411        let guard = self.inner.lock().await;
412        let runtime = guard
413            .as_ref()
414            .ok_or_else(|| ClusterRuntimeError::Config("cluster runtime not running".into()))?;
415        let control = ExecutionControl::default();
416        Ok(runtime
417            .write_tablet_ops(tablet_id, operations, &control)
418            .await?)
419    }
420
421    /// Graceful shutdown: stop the runtime once. Additional calls are no-ops.
422    pub async fn shutdown(&self) -> Result<(), ClusterRuntimeError> {
423        let runtime = {
424            let mut guard = self.inner.lock().await;
425            guard.take()
426        };
427        if let Some(runtime) = runtime {
428            runtime.shutdown().await?;
429        }
430        Ok(())
431    }
432}
433
434const LEADER_TIMEOUT: Duration = Duration::from_secs(15);
435
436fn env_flag_true(name: &str) -> bool {
437    match std::env::var(name) {
438        Ok(value) => {
439            let value = value.trim();
440            value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
441        }
442        Err(_) => false,
443    }
444}
445
446/// Bind-and-release when the operator requested port 0 so peer tables carry a
447/// concrete address before the runtime starts.
448fn resolve_listen_address(listen: &str) -> Result<String, ClusterRuntimeError> {
449    let trimmed = listen.trim();
450    if trimmed.is_empty() {
451        return Err(ClusterRuntimeError::Config(
452            "cluster RPC listen address is empty".into(),
453        ));
454    }
455    // Port 0 (or host:0) — allocate a free port up front.
456    if trimmed.ends_with(":0") {
457        let listener = std::net::TcpListener::bind(trimmed)?;
458        return Ok(listener.local_addr()?.to_string());
459    }
460    Ok(trimmed.to_owned())
461}
462
463fn resolve_security(
464    node_data: &Path,
465    plaintext_test: bool,
466) -> Result<TransportSecurity, ClusterRuntimeError> {
467    if plaintext_test {
468        // Explicit `plaintext_test: true` is the library test API (integration
469        // tests + unit tests). Production entry points must not set it:
470        // - `NodeRuntimeConfig::production` rejects plaintext (P1.3-T1)
471        // - `mongreldb-server` main refuses env-based plaintext unless
472        //   `dangerous-test-transport` / cfg(test) (P1.3-T4; see main.rs)
473        // Integration tests compile this lib without `cfg(test)`, so the gate
474        // cannot rely on cfg alone for the explicit option.
475        return Ok(TransportSecurity::PlaintextForTesting);
476    }
477    let trust = load_persisted_trust(node_data)?.ok_or_else(|| {
478        ClusterRuntimeError::Config(
479            "cluster trust material missing under cluster-meta/trust.json; \
480             run `mongreldb-server cluster init` first (production requires mTLS)"
481                .into(),
482        )
483    })?;
484    let tls = TlsConfig::from_trust(&trust).map_err(|error| {
485        ClusterRuntimeError::Config(format!(
486            "cluster trust PEMs are not usable for mTLS ({error}); \
487             supply real certificates from `cluster init` (production requires mTLS)"
488        ))
489    })?;
490    Ok(TransportSecurity::Mtls(tls))
491}
492
493/// On-disk envelope written by `bootstrap::write_trust` (private there).
494#[derive(Deserialize, Serialize)]
495#[serde(deny_unknown_fields)]
496struct TrustEnvelope {
497    format_version: u32,
498    trust: TrustConfig,
499}
500
501fn load_persisted_trust(node_data: &Path) -> Result<Option<TrustConfig>, ClusterRuntimeError> {
502    let path = node_data.join("cluster-meta").join("trust.json");
503    if !path.exists() {
504        return Ok(None);
505    }
506    let bytes = std::fs::read(&path)?;
507    let envelope: TrustEnvelope = serde_json::from_slice(&bytes).map_err(|error| {
508        ClusterRuntimeError::Config(format!("cluster-meta/trust.json is corrupt: {error}"))
509    })?;
510    if envelope.format_version != 1 {
511        return Err(ClusterRuntimeError::Config(format!(
512            "cluster-meta/trust.json format version {} is unsupported",
513            envelope.format_version
514        )));
515    }
516    envelope
517        .trust
518        .validate()
519        .map_err(ClusterRuntimeError::from)?;
520    Ok(Some(envelope.trust))
521}
522
523fn peers_from_status(status: &ClusterStatus, self_listen: &str) -> Vec<(NodeId, String)> {
524    if !status.membership.is_empty() {
525        return status
526            .membership
527            .iter()
528            .map(|member| {
529                let address = if member.node_id == status.identity.node_id {
530                    self_listen.to_owned()
531                } else {
532                    member.rpc_address.clone()
533                };
534                (member.node_id, address)
535            })
536            .collect();
537    }
538    // Join-only or partial bootstrap: at least advertise this node.
539    vec![(status.identity.node_id, self_listen.to_owned())]
540}
541
542/// First product path: this node hosts meta when it is a database-group
543/// voter. Sole-voter init bootstraps the pristine meta group; multi-voter
544/// members reopen without re-bootstrap.
545fn meta_membership_from_status(
546    status: &ClusterStatus,
547    self_listen: &str,
548) -> Option<MetaMembership> {
549    let group = status.database_group.as_ref()?;
550    if !group.voter_ids.contains(&status.identity.node_id) {
551        return None;
552    }
553    let bootstrap_voters = if group.voter_ids.len() == 1 {
554        Some(vec![(status.identity.node_id, self_listen.to_owned())])
555    } else {
556        None
557    };
558    Some(MetaMembership {
559        meta_group_id: group.raft_group_id,
560        bootstrap_voters,
561    })
562}
563
564fn transport_config(plaintext_test: bool) -> TransportConfig {
565    if plaintext_test {
566        TransportConfig {
567            connect_timeout: Duration::from_millis(500),
568            rpc_timeout: Duration::from_secs(2),
569            snapshot_timeout: Duration::from_secs(5),
570            connect_attempts: 5,
571            reconnect_backoff: Duration::from_millis(10),
572            max_frame_bytes: 16 * 1024 * 1024,
573            max_connections: 64,
574            handshake_timeout: Duration::from_secs(2),
575            shutdown_grace: Duration::from_secs(2),
576        }
577    } else {
578        TransportConfig::default()
579    }
580}
581
582fn fast_timing() -> GroupTiming {
583    GroupTiming {
584        heartbeat_interval: Duration::from_millis(100),
585        election_timeout_min: Duration::from_millis(300),
586        election_timeout_max: Duration::from_millis(600),
587        install_snapshot_timeout: Duration::from_millis(2_000),
588    }
589}
590
591fn parse_key_hex(text: &str) -> Result<Key, ClusterRuntimeError> {
592    let trimmed = text.trim();
593    if trimmed.is_empty() {
594        return Err(ClusterRuntimeError::Config("split key hex is empty".into()));
595    }
596    if !trimmed.len().is_multiple_of(2) {
597        return Err(ClusterRuntimeError::Config(
598            "split key hex must have an even number of digits".into(),
599        ));
600    }
601    let mut bytes = Vec::with_capacity(trimmed.len() / 2);
602    let chars: Vec<char> = trimmed.chars().collect();
603    for chunk in chars.chunks(2) {
604        let hi = chunk[0].to_digit(16).ok_or_else(|| {
605            ClusterRuntimeError::Config(format!("invalid split key hex `{text}`"))
606        })?;
607        let lo = chunk[1].to_digit(16).ok_or_else(|| {
608            ClusterRuntimeError::Config(format!("invalid split key hex `{text}`"))
609        })?;
610        bytes.push(((hi << 4) | lo) as u8);
611    }
612    Ok(Key::from_bytes(bytes))
613}
614
615fn runtime_status_to_json(status: &RuntimeStatus) -> Value {
616    json!({
617        "live": true,
618        "node_id": status.identity.node_id.to_string(),
619        "cluster_id": status.identity.cluster_id.to_string(),
620        "rpc_address": status.rpc_address,
621        "meta_present": status.meta.is_some(),
622        "meta": status.meta.as_ref().map(|meta| json!({
623            "meta_group_id": meta.meta_group_id.to_string(),
624            "metadata_version": meta.metadata_version.get(),
625            "current_leader": meta.metrics.current_leader,
626            "local_raft_node_id": meta.metrics.node_id,
627        })),
628        "tablet_count": status.tablets.len(),
629        "tablets": status.tablets.iter().map(|tablet| json!({
630            "tablet_id": tablet.tablet_id.to_string(),
631            "raft_group_id": tablet.raft_group_id.to_string(),
632            "state": tablet.state.to_string(),
633            "replica_count": tablet.replicas.len(),
634            "applied_index": tablet.applied.index,
635            "current_leader": tablet.metrics.current_leader,
636            "local_raft_node_id": tablet.metrics.node_id,
637        })).collect::<Vec<_>>(),
638    })
639}
640
641/// Resolve cluster node-data from CLI / environment. `None` means standalone.
642pub fn cluster_node_data_from_env(cli_value: Option<String>) -> Option<PathBuf> {
643    cli_value
644        .or_else(|| std::env::var("MONGRELDB_CLUSTER_NODE_DATA").ok())
645        .map(|s| s.trim().to_owned())
646        .filter(|s| !s.is_empty())
647        .map(PathBuf::from)
648}
649
650/// Whether plaintext cluster transport is admitted in this build.
651///
652/// Production binaries return `false` unless rebuilt with the
653/// `dangerous-test-transport` feature. Package unit tests return `true`.
654pub fn plaintext_cluster_transport_allowed() -> bool {
655    cfg!(any(test, feature = "dangerous-test-transport"))
656}
657
658/// Production gate for plaintext cluster transport (P1.3-T4 / P1.3-X2).
659///
660/// The daemon calls this with [`plaintext_cluster_transport_allowed`] before
661/// starting a runtime when `plaintext_test` was requested via env. The pure
662/// form takes an explicit `allowed` so product tests can exercise the refuse
663/// path without rebuilding under `cfg(test)`.
664pub fn admit_plaintext_cluster_transport(
665    plaintext_requested: bool,
666    allowed: bool,
667) -> Result<(), ClusterRuntimeError> {
668    if plaintext_requested && !allowed {
669        return Err(ClusterRuntimeError::Config(
670            "plaintext cluster transport is refused on the production path; \
671             configure mTLS via cluster-meta/trust.json (cluster init), or \
672             rebuild with the dangerous-test-transport feature for \
673             non-production tests only"
674                .into(),
675        ));
676    }
677    Ok(())
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use mongreldb_cluster::bootstrap::{cluster_init, InitRequest};
684    use mongreldb_cluster::node::{Locality, NodeCapacity};
685    use tempfile::tempdir;
686
687    const CA_PEM: &str = "-----BEGIN CERTIFICATE-----\nY2E=\n-----END CERTIFICATE-----\n";
688    const CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\nbm9kZQ==\n-----END CERTIFICATE-----\n";
689    const KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nc2VjcmV0\n-----END PRIVATE KEY-----\n";
690
691    fn bootstrap(data: &Path) -> NodeIdentity {
692        let mut counter = 0u64;
693        let mut csprng = |buf: &mut [u8]| {
694            for chunk in buf.chunks_mut(8) {
695                counter += 1;
696                let bytes = counter.to_le_bytes();
697                chunk.copy_from_slice(&bytes[..chunk.len()]);
698            }
699            Ok(())
700        };
701        let identity = NodeIdentity::load_or_create(data, &mut csprng).unwrap();
702        let request = InitRequest {
703            rpc_address: "127.0.0.1:0".to_owned(),
704            locality: Locality::default(),
705            capacity: NodeCapacity::default(),
706            trust: TrustConfig::from_pems(
707                CA_PEM.to_owned(),
708                CERT_PEM.to_owned(),
709                KEY_PEM.to_owned(),
710                vec![identity.node_id],
711            )
712            .unwrap(),
713        };
714        cluster_init(data, &request, &mut csprng).unwrap().identity
715    }
716
717    #[tokio::test]
718    async fn plaintext_start_status_and_missing_tablet_errors() {
719        let dir = tempdir().unwrap();
720        let identity = bootstrap(dir.path());
721        let listen = {
722            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
723            listener.local_addr().unwrap().to_string()
724        };
725        let handle = ClusterRuntimeHandle::start(ClusterRuntimeOptions {
726            node_data: dir.path().to_path_buf(),
727            rpc_listen: listen.clone(),
728            plaintext_test: true,
729            fast_timing: true,
730        })
731        .await
732        .expect("runtime starts after cluster init");
733
734        assert!(handle.is_live().await);
735        let status = handle.runtime_status_json().await.unwrap();
736        assert_eq!(status["live"], true);
737        assert_eq!(status["node_id"], identity.node_id.to_string());
738        assert_eq!(status["rpc_address"], listen);
739        assert_eq!(status["meta_present"], true);
740        assert_eq!(status["tablet_count"], 0);
741
742        let missing = TabletId::from_bytes([0xAB; 16]);
743        let err = handle
744            .transfer_leader(missing, identity.node_id)
745            .await
746            .unwrap_err();
747        assert!(
748            err.to_string().contains("hosts no live tablet"),
749            "transfer without tablet must fail closed: {err}"
750        );
751        let err = handle.split_tablet(missing, None).await.unwrap_err();
752        assert!(
753            err.to_string().contains("hosts no live tablet"),
754            "split without tablet must fail closed: {err}"
755        );
756
757        handle.shutdown().await.unwrap();
758        assert!(!handle.is_live().await);
759    }
760
761    #[tokio::test]
762    async fn start_fails_closed_without_cluster_init() {
763        let dir = tempdir().unwrap();
764        match ClusterRuntimeHandle::start(ClusterRuntimeOptions {
765            node_data: dir.path().to_path_buf(),
766            rpc_listen: "127.0.0.1:0".into(),
767            plaintext_test: true,
768            fast_timing: true,
769        })
770        .await
771        {
772            Ok(_) => panic!("expected NotInitialized without cluster init"),
773            Err(err) => assert!(
774                matches!(
775                    err,
776                    ClusterRuntimeError::Cluster(ClusterError::NotInitialized)
777                ) || err
778                    .to_string()
779                    .to_ascii_lowercase()
780                    .contains("not initialized")
781                    || err.to_string().contains("NotInitialized"),
782                "expected NotInitialized, got {err}"
783            ),
784        }
785    }
786
787    #[test]
788    fn parse_key_hex_round_trip() {
789        let key = parse_key_hex("0a1b").unwrap();
790        assert_eq!(key.as_bytes(), &[0x0a, 0x1b]);
791        assert!(parse_key_hex("zz").is_err());
792        assert!(parse_key_hex("abc").is_err());
793    }
794
795    #[test]
796    fn plaintext_gate_open_under_package_tests() {
797        // Production binaries compile this false (without the feature).
798        // Package tests keep the loopback escape hatch open (P1.3-T4 / X2).
799        assert!(plaintext_cluster_transport_allowed());
800    }
801
802    /// P1.3-X2: production gate refuses plaintext when the build disallows it.
803    #[test]
804    fn p13_x2_production_gate_refuses_plaintext_when_disallowed() {
805        let err = admit_plaintext_cluster_transport(true, false).expect_err("must refuse");
806        assert!(
807            err.to_string()
808                .contains("plaintext cluster transport is refused"),
809            "{err}"
810        );
811        // Allowed escape hatch (test builds / dangerous-test-transport).
812        admit_plaintext_cluster_transport(true, true).expect("allowed when admitted");
813        // Production mTLS path does not request plaintext.
814        admit_plaintext_cluster_transport(false, false)
815            .expect("mTLS path never needs escape hatch");
816    }
817
818    /// P1.3-X2: production start path (`plaintext_test: false`) requires usable mTLS.
819    #[tokio::test]
820    async fn p13_x2_non_plaintext_start_requires_usable_mtls() {
821        let dir = tempdir().unwrap();
822        bootstrap(dir.path());
823        let listen = {
824            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
825            listener.local_addr().unwrap().to_string()
826        };
827        // Dummy bootstrap PEMs are not real X.509; production mTLS path fails closed.
828        let err = match ClusterRuntimeHandle::start(ClusterRuntimeOptions {
829            node_data: dir.path().to_path_buf(),
830            rpc_listen: listen,
831            plaintext_test: false,
832            fast_timing: true,
833        })
834        .await
835        {
836            Ok(handle) => {
837                let _ = handle.shutdown().await;
838                panic!("dummy trust PEMs must fail mTLS construction");
839            }
840            Err(error) => error,
841        };
842        let message = err.to_string().to_ascii_lowercase();
843        assert!(
844            message.contains("mtls")
845                || message.contains("trust")
846                || message.contains("certificate")
847                || message.contains("pem"),
848            "expected mTLS/trust failure, got {err}"
849        );
850    }
851}