1use 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#[derive(Clone, Debug)]
43pub struct ClusterRuntimeOptions {
44 pub node_data: PathBuf,
46 pub rpc_listen: String,
48 pub plaintext_test: bool,
50 pub fast_timing: bool,
52}
53
54impl ClusterRuntimeOptions {
55 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_timing: plaintext_test,
73 }
74 }
75}
76
77#[derive(Clone)]
84pub struct ClusterRuntimeHandle {
85 inner: Arc<Mutex<Option<NodeRuntime>>>,
86 node_data: PathBuf,
87}
88
89#[derive(Debug)]
91pub enum ClusterRuntimeError {
92 Cluster(ClusterError),
94 Runtime(RuntimeError),
96 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 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 pub fn node_data(&self) -> &Path {
170 &self.node_data
171 }
172
173 pub async fn is_live(&self) -> bool {
175 self.inner.lock().await.is_some()
176 }
177
178 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 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 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 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 pub fn runtime_mutex(&self) -> Arc<Mutex<Option<NodeRuntime>>> {
286 Arc::clone(&self.inner)
287 }
288
289 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 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 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 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 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 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 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 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 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 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 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
446fn 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 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 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#[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 vec![(status.identity.node_id, self_listen.to_owned())]
540}
541
542fn 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
641pub 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
650pub fn plaintext_cluster_transport_allowed() -> bool {
655 cfg!(any(test, feature = "dangerous-test-transport"))
656}
657
658pub 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 assert!(plaintext_cluster_transport_allowed());
800 }
801
802 #[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 admit_plaintext_cluster_transport(true, true).expect("allowed when admitted");
813 admit_plaintext_cluster_transport(false, false)
815 .expect("mTLS path never needs escape hatch");
816 }
817
818 #[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 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}