1use std::pin::Pin;
75use std::sync::atomic::{AtomicU64, Ordering};
76use std::sync::Arc;
77use std::task::{Context, Poll};
78use std::time::{Duration, SystemTime};
79
80use futures::Stream;
81use tokio::time::{interval, Interval};
82
83use super::meshos::{
84 ice_proposal_signing_payload, simulate_ice_proposal, AdminEvent, BlastRadius, ChainId,
85 IceActionProposal, MeshOsEvent, MeshOsHandle, MeshOsHandleError, MeshOsRuntime, MeshOsSnapshot,
86 MeshOsSnapshotReader, NodeId,
87};
88use crate::adapter::net::behavior::aggregator::{AggregatorDaemon, SummaryAnnouncement};
89use crate::adapter::net::identity::EntityKeypair;
90use crate::adapter::net::subnet::SubnetId;
91use crate::adapter::net::MeshNode;
92use crate::adapter::net::{ChannelHash, Visibility};
93
94#[derive(Clone, Debug)]
101pub struct OperatorIdentity {
102 keypair: Arc<EntityKeypair>,
103 operator_id: u64,
104}
105
106impl OperatorIdentity {
107 pub fn from_keypair(keypair: EntityKeypair) -> Self {
110 let operator_id = keypair.origin_hash();
111 Self {
112 keypair: Arc::new(keypair),
113 operator_id,
114 }
115 }
116
117 pub fn generate() -> Self {
120 Self::from_keypair(EntityKeypair::generate())
121 }
122
123 pub fn operator_id(&self) -> u64 {
126 self.operator_id
127 }
128
129 pub fn keypair(&self) -> &EntityKeypair {
140 &self.keypair
141 }
142}
143
144#[derive(Clone, Debug, thiserror::Error)]
148#[error("<<deck-sdk-kind:{kind}>>{message}")]
149pub struct DeckError {
150 pub kind: &'static str,
154 pub message: String,
156}
157
158impl DeckError {
159 fn new(kind: &'static str, message: impl Into<String>) -> Self {
160 Self {
161 kind,
162 message: message.into(),
163 }
164 }
165}
166
167impl From<MeshOsHandleError> for DeckError {
168 fn from(err: MeshOsHandleError) -> Self {
169 match err {
170 MeshOsHandleError::LoopClosed => Self::new("loop_closed", "MeshOS loop has exited"),
171 MeshOsHandleError::QueueFull => Self::new(
172 "queue_full",
173 "MeshOS source channel at capacity — back off + retry",
174 ),
175 }
176 }
177}
178
179pub type AdminError = DeckError;
183
184pub type IceError = DeckError;
188
189#[derive(Clone, Debug)]
198pub struct ChainCommit {
199 commit_id: u64,
200 operator_id: u64,
201 event_kind: &'static str,
202 committed_at: SystemTime,
203}
204
205impl ChainCommit {
206 pub fn commit_id(&self) -> u64 {
209 self.commit_id
210 }
211
212 pub fn operator_id(&self) -> u64 {
214 self.operator_id
215 }
216
217 pub fn event_kind(&self) -> &'static str {
220 self.event_kind
221 }
222
223 pub fn committed_at(&self) -> SystemTime {
227 self.committed_at
228 }
229}
230
231#[derive(Clone, Debug)]
233pub struct DeckClientConfig {
234 pub snapshot_poll_interval: Duration,
239 pub ice_signature_threshold: usize,
248}
249
250impl Default for DeckClientConfig {
251 fn default() -> Self {
252 Self {
253 snapshot_poll_interval: Duration::from_millis(100),
254 ice_signature_threshold: 1,
255 }
256 }
257}
258
259#[derive(Clone, Debug, Default, Eq, PartialEq)]
267pub struct StatusSummary {
268 pub peers: PeerCounts,
270 pub daemons: DaemonCounts,
272 pub replica_chains: usize,
274 pub avoid_list_entries: usize,
276 pub recently_emitted_count: usize,
283 pub recent_failure_count: usize,
285 pub admin_audit_ring_depth: usize,
288 pub freeze_remaining_ms: Option<u64>,
291 pub local_maintenance_active: bool,
295}
296
297#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
299pub struct PeerCounts {
300 pub healthy: usize,
302 pub degraded: usize,
304 pub unreachable: usize,
306 pub unknown: usize,
308}
309
310#[derive(Clone, Debug, Eq, PartialEq)]
315pub struct GatewayStats {
316 pub local_subnet: SubnetId,
321 pub forwarded: u64,
326 pub dropped: u64,
328 pub peer_subnets: Vec<SubnetId>,
331 pub export_rules: u64,
334}
335
336#[derive(Clone, Debug, Eq, PartialEq)]
341pub struct SubnetRollup {
342 pub subnet: SubnetId,
344 pub members: Vec<u64>,
348 pub is_local: bool,
351}
352
353#[derive(Clone, Debug)]
359pub struct AggregatorSnapshot {
360 pub source_subnet: SubnetId,
362 pub fold_kinds: Vec<u16>,
364 pub generation: u64,
366 pub summary_interval: std::time::Duration,
368 pub summaries: Arc<Vec<SummaryAnnouncement>>,
370}
371
372#[derive(Clone, Debug)]
375pub struct AggregatorReplicaRow {
376 pub generation: u64,
378 pub healthy: bool,
381 pub diagnostic: Option<String>,
383 pub placement_node_id: Option<u64>,
387}
388
389#[derive(Clone, Debug)]
393pub struct AggregatorRegistryGroupSnapshot {
394 pub name: String,
396 pub group_seed: [u8; 32],
398 pub replicas: Vec<AggregatorReplicaRow>,
400}
401
402#[derive(Clone, Debug, Default)]
404pub struct AggregatorRegistrySnapshot {
405 pub groups: Vec<AggregatorRegistryGroupSnapshot>,
408}
409
410#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
416pub struct DaemonCounts {
417 pub running: usize,
419 pub starting: usize,
421 pub stopping: usize,
423 pub stopped: usize,
425 pub backing_off: usize,
429 pub crash_looping: usize,
432}
433
434fn build_status_summary(snap: &MeshOsSnapshot) -> StatusSummary {
438 let mut peers = PeerCounts::default();
439 for peer in snap.peers.values() {
440 match peer.health {
441 Some(super::meshos::PeerHealthSnapshot::Healthy) => peers.healthy += 1,
442 Some(super::meshos::PeerHealthSnapshot::Degraded) => peers.degraded += 1,
443 Some(super::meshos::PeerHealthSnapshot::Unreachable) => peers.unreachable += 1,
444 None => peers.unknown += 1,
445 }
446 }
447 let mut daemons = DaemonCounts::default();
448 for d in snap.daemons.values() {
449 match d.lifecycle {
450 super::meshos::DaemonLifecycleSnapshot::Running => daemons.running += 1,
451 super::meshos::DaemonLifecycleSnapshot::Starting => daemons.starting += 1,
452 super::meshos::DaemonLifecycleSnapshot::Stopping => daemons.stopping += 1,
453 super::meshos::DaemonLifecycleSnapshot::Stopped => daemons.stopped += 1,
454 }
455 match d.restart_state {
456 super::meshos::RestartStateSnapshot::Idle => {}
457 super::meshos::RestartStateSnapshot::BackingOff { .. } => daemons.backing_off += 1,
458 super::meshos::RestartStateSnapshot::CrashLooping { .. } => daemons.crash_looping += 1,
459 }
460 }
461 let maintenance_active = !matches!(
462 snap.local_maintenance,
463 super::meshos::MaintenanceStateSnapshot::Active
464 );
465 StatusSummary {
466 peers,
467 daemons,
468 replica_chains: snap.replicas.len(),
469 avoid_list_entries: snap.avoid_list.len(),
470 recently_emitted_count: snap.recently_emitted.len(),
471 recent_failure_count: snap.recent_failures.len(),
472 admin_audit_ring_depth: snap.admin_audit.len(),
473 freeze_remaining_ms: snap.freeze_remaining_ms,
474 local_maintenance_active: maintenance_active,
475 }
476}
477
478#[derive(Clone)]
487pub struct DeckClient {
488 handle: MeshOsHandle,
489 snapshot_reader: MeshOsSnapshotReader,
490 identity: OperatorIdentity,
491 config: DeckClientConfig,
492 commit_seq: Arc<AtomicU64>,
498 operator_registry: Option<Arc<OperatorRegistry>>,
505 mesh: Option<Arc<MeshNode>>,
513 aggregator: Option<Arc<AggregatorDaemon>>,
521}
522
523impl DeckClient {
524 pub fn new(
529 handle: MeshOsHandle,
530 snapshot_reader: MeshOsSnapshotReader,
531 identity: OperatorIdentity,
532 config: DeckClientConfig,
533 ) -> Self {
534 Self {
535 handle,
536 snapshot_reader,
537 identity,
538 config,
539 commit_seq: Arc::new(AtomicU64::new(0)),
540 operator_registry: None,
541 mesh: None,
542 aggregator: None,
543 }
544 }
545
546 pub fn with_mesh(mut self, mesh: Arc<MeshNode>) -> Self {
554 self.mesh = Some(mesh);
555 self
556 }
557
558 pub fn with_aggregator(mut self, aggregator: Arc<AggregatorDaemon>) -> Self {
561 self.aggregator = Some(aggregator);
562 self
563 }
564
565 pub fn from_runtime(runtime: &MeshOsRuntime, identity: OperatorIdentity) -> Self {
570 Self::new(
571 runtime.handle_clone(),
572 runtime.snapshot_reader().clone(),
573 identity,
574 DeckClientConfig::default(),
575 )
576 }
577
578 pub fn with_config(mut self, config: DeckClientConfig) -> Self {
581 self.config = config;
582 self
583 }
584
585 pub fn with_operator_registry(mut self, registry: OperatorRegistry) -> Self {
591 self.operator_registry = Some(Arc::new(registry));
592 self
593 }
594
595 pub fn operator_registry(&self) -> Option<&OperatorRegistry> {
597 self.operator_registry.as_deref()
598 }
599
600 pub fn identity(&self) -> &OperatorIdentity {
602 &self.identity
603 }
604
605 pub fn admin(&self) -> AdminCommands<'_> {
609 AdminCommands { client: self }
610 }
611
612 pub fn ice(&self) -> IceCommands<'_> {
617 IceCommands { client: self }
618 }
619
620 pub fn audit(&self) -> AuditQuery<'_> {
629 AuditQuery::new(self)
630 }
631
632 pub fn subscribe_failures(&self, since_seq: u64) -> FailureStream {
641 FailureStream::new(
642 self.snapshot_reader.clone(),
643 self.config.snapshot_poll_interval,
644 since_seq,
645 )
646 }
647
648 pub fn subscribe_logs(&self, filter: LogFilter) -> LogStream {
658 LogStream::new(
659 self.snapshot_reader.clone(),
660 self.config.snapshot_poll_interval,
661 filter,
662 )
663 }
664
665 pub fn snapshots(&self) -> SnapshotStream {
671 SnapshotStream::new(
672 self.snapshot_reader.clone(),
673 self.config.snapshot_poll_interval,
674 )
675 }
676
677 pub fn status(&self) -> MeshOsSnapshot {
683 self.snapshot_reader.read()
684 }
685
686 pub fn status_summary_stream(&self) -> StatusSummaryStream {
694 StatusSummaryStream::new(
695 self.snapshot_reader.clone(),
696 self.config.snapshot_poll_interval,
697 )
698 }
699
700 pub fn status_summary(&self) -> StatusSummary {
706 build_status_summary(&self.snapshot_reader.load())
707 }
708
709 pub fn peers(&self) -> std::collections::BTreeMap<NodeId, super::meshos::PeerSnapshot> {
712 self.snapshot_reader.load().peers.clone()
713 }
714
715 pub fn local_subnet(&self) -> Option<SubnetId> {
719 self.mesh.as_ref().map(|m| m.local_subnet())
720 }
721
722 pub fn known_subnets(&self) -> Vec<(u64, SubnetId)> {
728 self.mesh
729 .as_ref()
730 .map(|m| m.known_subnets())
731 .unwrap_or_default()
732 }
733
734 pub fn subnets_with_members(&self, local_node_id: Option<u64>) -> Vec<SubnetRollup> {
744 let local = self.local_subnet();
745 let mut buckets: std::collections::BTreeMap<u32, std::collections::BTreeSet<u64>> =
746 std::collections::BTreeMap::new();
747 for (node_id, subnet) in self.known_subnets() {
748 buckets.entry(subnet.raw()).or_default().insert(node_id);
749 }
750 if let Some(local_subnet) = local {
751 let entry = buckets.entry(local_subnet.raw()).or_default();
752 if let Some(id) = local_node_id {
753 entry.insert(id);
754 }
755 }
756 buckets
757 .into_iter()
758 .map(|(raw, members)| {
759 let subnet = SubnetId::from_raw(raw);
760 SubnetRollup {
761 subnet,
762 members: members.into_iter().collect(),
763 is_local: local == Some(subnet),
764 }
765 })
766 .collect()
767 }
768
769 pub fn gateway_stats(&self) -> Option<GatewayStats> {
774 let gw = self.mesh.as_ref().and_then(|m| m.gateway())?;
775 Some(GatewayStats {
776 local_subnet: gw.local_subnet(),
777 forwarded: gw.forwarded_count(),
778 dropped: gw.dropped_count(),
779 peer_subnets: gw.peer_subnets(),
780 export_rules: gw.exports().len() as u64,
781 })
782 }
783
784 pub fn gateway_exports(&self) -> Vec<(u16, Vec<SubnetId>)> {
788 self.mesh
789 .as_ref()
790 .and_then(|m| m.gateway())
791 .map(|gw| gw.exports())
792 .unwrap_or_default()
793 }
794
795 pub fn channel_visibility(&self, channel_name: &str) -> Option<Visibility> {
801 let mesh = self.mesh.as_ref()?;
802 let registry = mesh.channel_configs()?;
803 let cfg = registry.get_by_name(channel_name)?;
804 Some(cfg.visibility)
805 }
806
807 pub fn channels(&self) -> Vec<(String, Visibility)> {
811 let Some(mesh) = self.mesh.as_ref() else {
812 return Vec::new();
813 };
814 let Some(registry) = mesh.channel_configs() else {
815 return Vec::new();
816 };
817 registry
818 .snapshot()
819 .into_iter()
820 .map(|(name, cfg)| (name, cfg.visibility))
821 .collect()
822 }
823
824 pub fn channel_wire_hash(&self, channel_name: &str) -> Option<u16> {
831 let mesh = self.mesh.as_ref()?;
832 let registry = mesh.channel_configs()?;
833 let cfg = registry.get_by_name(channel_name)?;
834 Some(cfg.channel_id.wire_hash())
835 }
836
837 pub fn channel_canonical_hash(&self, channel_name: &str) -> Option<ChannelHash> {
841 let mesh = self.mesh.as_ref()?;
842 let registry = mesh.channel_configs()?;
843 let cfg = registry.get_by_name(channel_name)?;
844 Some(cfg.channel_id.hash())
845 }
846
847 pub fn aggregator_installed(&self) -> bool {
852 self.aggregator.is_some()
853 }
854
855 pub fn aggregator_summaries(&self) -> Vec<SummaryAnnouncement> {
858 self.aggregator
859 .as_ref()
860 .map(|a| a.latest_summaries())
861 .unwrap_or_default()
862 }
863
864 pub fn aggregator_summaries_arc(&self) -> Arc<Vec<SummaryAnnouncement>> {
868 self.aggregator
869 .as_ref()
870 .map(|a| a.latest_summaries_arc())
871 .unwrap_or_else(|| Arc::new(Vec::new()))
872 }
873
874 pub fn aggregator_snapshot(&self) -> Option<AggregatorSnapshot> {
884 let agg = self.aggregator.as_ref()?;
885 let config = agg.config();
886 Some(AggregatorSnapshot {
887 source_subnet: config.source_subnet,
888 fold_kinds: config.fold_kinds.clone(),
889 generation: agg.generation(),
890 summary_interval: config.summary_interval,
891 summaries: agg.latest_summaries_arc(),
892 })
893 }
894
895 pub async fn aggregator_registry_snapshot(&self) -> Option<AggregatorRegistrySnapshot> {
906 let mesh = self.mesh.as_ref()?;
907 let registry = mesh.aggregator_registry()?;
908 let entries = registry.entries();
909 let mut groups = Vec::with_capacity(entries.len());
910 for entry in entries {
911 let snap = entry.snapshot().await;
919 let rows = snap
920 .replicas
921 .iter()
922 .enumerate()
923 .map(|(idx, replica)| {
924 let health = snap.healths.get(idx).cloned().unwrap_or(
925 crate::adapter::net::behavior::lifecycle::ReplicaHealth {
926 healthy: true,
927 diagnostic: None,
928 },
929 );
930 let placement_node_id = snap.placements.get(idx).map(|p| p.node_id);
931 AggregatorReplicaRow {
932 generation: replica.generation(),
933 healthy: health.healthy,
934 diagnostic: health.diagnostic,
935 placement_node_id,
936 }
937 })
938 .collect();
939 groups.push(AggregatorRegistryGroupSnapshot {
940 name: entry.name.clone(),
941 group_seed: entry.group_seed,
942 replicas: rows,
943 });
944 }
945 Some(AggregatorRegistrySnapshot { groups })
946 }
947
948 pub fn aggregator_generation(&self) -> u64 {
951 self.aggregator
952 .as_ref()
953 .map(|a| a.generation())
954 .unwrap_or(0)
955 }
956
957 pub fn aggregator_source_subnet(&self) -> Option<SubnetId> {
960 self.aggregator.as_ref().map(|a| a.config().source_subnet)
961 }
962
963 pub fn aggregator_fold_kinds(&self) -> Vec<u16> {
966 self.aggregator
967 .as_ref()
968 .map(|a| a.config().fold_kinds.clone())
969 .unwrap_or_default()
970 }
971
972 pub fn aggregator_summary_interval(&self) -> std::time::Duration {
974 self.aggregator
975 .as_ref()
976 .map(|a| a.config().summary_interval)
977 .unwrap_or_default()
978 }
979
980 pub fn daemons(&self) -> std::collections::BTreeMap<u64, super::meshos::DaemonSnapshot> {
982 self.snapshot_reader.load().daemons.clone()
983 }
984
985 pub fn replicas(&self) -> std::collections::BTreeMap<ChainId, super::meshos::ReplicaSnapshot> {
987 self.snapshot_reader.load().replicas.clone()
988 }
989
990 pub fn local_maintenance(&self) -> super::meshos::MaintenanceStateSnapshot {
992 self.snapshot_reader.load().local_maintenance.clone()
993 }
994
995 pub fn freeze_remaining_ms(&self) -> Option<u64> {
998 self.snapshot_reader.load().freeze_remaining_ms
999 }
1000
1001 pub fn recent_failures(&self) -> Vec<super::meshos::FailureRecord> {
1007 self.snapshot_reader
1008 .load()
1009 .recent_failures
1010 .iter()
1011 .cloned()
1012 .collect()
1013 }
1014
1015 pub fn runtime_epoch_id(&self) -> u64 {
1023 self.snapshot_reader.load().runtime_epoch_id
1024 }
1025
1026 pub fn audit_head_seq(&self) -> u64 {
1033 self.snapshot_reader
1034 .load()
1035 .admin_audit
1036 .last()
1037 .map(|r| r.seq)
1038 .unwrap_or(0)
1039 }
1040
1041 pub fn log_head_seq(&self) -> u64 {
1045 self.snapshot_reader
1046 .load()
1047 .log_ring
1048 .last()
1049 .map(|r| r.seq)
1050 .unwrap_or(0)
1051 }
1052
1053 pub fn failure_head_seq(&self) -> u64 {
1056 self.snapshot_reader
1057 .load()
1058 .recent_failures
1059 .iter()
1060 .next_back()
1061 .map(|r| r.seq)
1062 .unwrap_or(0)
1063 }
1064
1065 pub fn recent_failures_since(&self, since_ms: u64) -> Vec<super::meshos::FailureRecord> {
1077 self.snapshot_reader
1078 .load()
1079 .recent_failures
1080 .iter()
1081 .filter(|r| r.recorded_at_ms > since_ms)
1082 .cloned()
1083 .collect()
1084 }
1085
1086 pub async fn watch<F>(&self, mut predicate: F) -> MeshOsSnapshot
1102 where
1103 F: FnMut(&MeshOsSnapshot) -> bool,
1104 {
1105 let snap = self.snapshot_reader.read();
1108 if predicate(&snap) {
1109 return snap;
1110 }
1111 let ceiling = self
1112 .config
1113 .snapshot_poll_interval
1114 .max(Duration::from_millis(1));
1115 let mut change_rx = self.snapshot_reader.subscribe_changes();
1122 loop {
1123 tokio::select! {
1124 biased;
1125 _ = change_rx.changed() => {}
1126 _ = tokio::time::sleep(ceiling) => {}
1127 }
1128 let snap = self.snapshot_reader.read();
1129 if predicate(&snap) {
1130 return snap;
1131 }
1132 }
1133 }
1134
1135 pub async fn watch_timeout<F>(
1140 &self,
1141 predicate: F,
1142 timeout: Duration,
1143 ) -> Result<MeshOsSnapshot, DeckError>
1144 where
1145 F: FnMut(&MeshOsSnapshot) -> bool,
1146 {
1147 tokio::time::timeout(timeout, self.watch(predicate))
1148 .await
1149 .map_err(|_| {
1150 DeckError::new(
1151 "watch_timeout",
1152 format!(
1153 "no snapshot matched the predicate within {} ms",
1154 timeout.as_millis()
1155 ),
1156 )
1157 })
1158 }
1159
1160 fn next_commit_id(&self) -> u64 {
1161 self.commit_seq.fetch_add(1, Ordering::Relaxed) + 1
1164 }
1165
1166 async fn publish_admin(
1167 &self,
1168 event: AdminEvent,
1169 kind: &'static str,
1170 ) -> Result<ChainCommit, AdminError> {
1171 let wire_event = if self.operator_registry.is_some() {
1180 let issued_at_ms = super::meshos::now_ms_since_unix_epoch();
1181 let signature = self.identity.sign_admin_event(&event, issued_at_ms);
1182 MeshOsEvent::SignedAdminCommit {
1183 event,
1184 signature,
1185 issued_at_ms,
1186 }
1187 } else {
1188 MeshOsEvent::AdminEvent(event)
1189 };
1190 self.handle
1191 .publish(wire_event)
1192 .await
1193 .map_err(AdminError::from)?;
1194 Ok(ChainCommit {
1195 commit_id: self.next_commit_id(),
1196 operator_id: self.identity.operator_id,
1197 event_kind: kind,
1198 committed_at: SystemTime::now(),
1199 })
1200 }
1201
1202 async fn publish_signed_ice(
1203 &self,
1204 proposal: IceActionProposal,
1205 signatures: Vec<OperatorSignature>,
1206 issued_at_ms: u64,
1207 blast_hash: super::meshos::BlastRadiusHash,
1208 kind: &'static str,
1209 ) -> Result<ChainCommit, IceError> {
1210 self.handle
1211 .publish(MeshOsEvent::SignedIceCommit {
1212 proposal,
1213 signatures,
1214 issued_at_ms,
1215 blast_hash,
1216 })
1217 .await
1218 .map_err(IceError::from)?;
1219 Ok(ChainCommit {
1220 commit_id: self.next_commit_id(),
1221 operator_id: self.identity.operator_id,
1222 event_kind: kind,
1223 committed_at: SystemTime::now(),
1224 })
1225 }
1226}
1227
1228pub struct AdminCommands<'a> {
1238 client: &'a DeckClient,
1239}
1240
1241impl AdminCommands<'_> {
1242 pub async fn drain(
1251 &self,
1252 node: NodeId,
1253 drain_for: Duration,
1254 ) -> Result<ChainCommit, AdminError> {
1255 self.client
1256 .publish_admin(AdminEvent::Drain { node, drain_for }, "drain")
1257 .await
1258 }
1259
1260 pub async fn enter_maintenance(
1265 &self,
1266 node: NodeId,
1267 drain_for: Option<Duration>,
1268 ) -> Result<ChainCommit, AdminError> {
1269 self.client
1270 .publish_admin(
1271 AdminEvent::EnterMaintenance { node, drain_for },
1272 "enter_maintenance",
1273 )
1274 .await
1275 }
1276
1277 pub async fn exit_maintenance(&self, node: NodeId) -> Result<ChainCommit, AdminError> {
1279 self.client
1280 .publish_admin(AdminEvent::ExitMaintenance { node }, "exit_maintenance")
1281 .await
1282 }
1283
1284 pub async fn cordon(&self, node: NodeId) -> Result<ChainCommit, AdminError> {
1287 self.client
1288 .publish_admin(AdminEvent::Cordon { node }, "cordon")
1289 .await
1290 }
1291
1292 pub async fn uncordon(&self, node: NodeId) -> Result<ChainCommit, AdminError> {
1294 self.client
1295 .publish_admin(AdminEvent::Uncordon { node }, "uncordon")
1296 .await
1297 }
1298
1299 pub async fn drop_replicas(
1301 &self,
1302 node: NodeId,
1303 chains: Vec<ChainId>,
1304 ) -> Result<ChainCommit, AdminError> {
1305 self.client
1306 .publish_admin(AdminEvent::DropReplicas { node, chains }, "drop_replicas")
1307 .await
1308 }
1309
1310 pub async fn invalidate_placement(&self, node: NodeId) -> Result<ChainCommit, AdminError> {
1312 self.client
1313 .publish_admin(
1314 AdminEvent::InvalidatePlacement { node },
1315 "invalidate_placement",
1316 )
1317 .await
1318 }
1319
1320 pub async fn restart_all_daemons(&self, node: NodeId) -> Result<ChainCommit, AdminError> {
1322 self.client
1323 .publish_admin(
1324 AdminEvent::RestartAllDaemons { node },
1325 "restart_all_daemons",
1326 )
1327 .await
1328 }
1329
1330 pub async fn clear_avoid_list(&self, node: NodeId) -> Result<ChainCommit, AdminError> {
1332 self.client
1333 .publish_admin(AdminEvent::ClearAvoidList { node }, "clear_avoid_list")
1334 .await
1335 }
1336}
1337
1338pub use super::meshos::{OperatorRegistry, OperatorSignature, VerifyError};
1343
1344impl OperatorIdentity {
1345 pub fn sign_proposal(
1361 &self,
1362 proposal: &IceActionProposal,
1363 issued_at_ms: u64,
1364 blast_hash: &super::meshos::BlastRadiusHash,
1365 ) -> OperatorSignature {
1366 OperatorSignature::sign(self.keypair(), proposal, issued_at_ms, blast_hash)
1367 }
1368
1369 pub fn sign_admin_event(&self, event: &AdminEvent, issued_at_ms: u64) -> OperatorSignature {
1376 OperatorSignature::sign_admin(self.keypair(), event, issued_at_ms)
1377 }
1378}
1379
1380fn verify_error_to_ice(err: VerifyError) -> IceError {
1384 let kind = err.kind();
1385 IceError::new(kind, err.to_string())
1386}
1387
1388pub struct IceCommands<'a> {
1394 client: &'a DeckClient,
1395}
1396
1397impl<'a> IceCommands<'a> {
1398 pub fn freeze_cluster(&self, ttl: Duration) -> IceProposal<'a> {
1401 IceProposal::new(self.client, IceActionProposal::FreezeCluster { ttl })
1402 }
1403
1404 pub fn flush_avoid_lists(&self, scope: super::meshos::AvoidScope) -> IceProposal<'a> {
1408 IceProposal::new(self.client, IceActionProposal::FlushAvoidLists { scope })
1409 }
1410
1411 pub fn force_evict_replica(&self, chain: ChainId, victim: NodeId) -> IceProposal<'a> {
1416 IceProposal::new(
1417 self.client,
1418 IceActionProposal::ForceEvictReplica { chain, victim },
1419 )
1420 }
1421
1422 pub fn force_restart_daemon(&self, daemon: super::meshos::DaemonRef) -> IceProposal<'a> {
1427 IceProposal::new(
1428 self.client,
1429 IceActionProposal::ForceRestartDaemon { daemon },
1430 )
1431 }
1432
1433 pub fn force_cutover(&self, chain: ChainId, target: NodeId) -> IceProposal<'a> {
1439 IceProposal::new(
1440 self.client,
1441 IceActionProposal::ForceCutover { chain, target },
1442 )
1443 }
1444
1445 pub fn kill_migration(&self, migration: super::meshos::MigrationId) -> IceProposal<'a> {
1453 IceProposal::new(self.client, IceActionProposal::KillMigration { migration })
1454 }
1455
1456 pub fn thaw_cluster(&self) -> IceProposal<'a> {
1458 IceProposal::new(self.client, IceActionProposal::ThawCluster)
1459 }
1460}
1461
1462pub struct IceProposal<'a> {
1478 client: &'a DeckClient,
1479 action: IceActionProposal,
1480 issued_at_ms: u64,
1481}
1482
1483impl<'a> IceProposal<'a> {
1484 fn new(client: &'a DeckClient, action: IceActionProposal) -> Self {
1485 Self {
1486 client,
1487 action,
1488 issued_at_ms: super::meshos::now_ms_since_unix_epoch(),
1489 }
1490 }
1491
1492 pub fn action(&self) -> &IceActionProposal {
1494 &self.action
1495 }
1496
1497 pub fn issued_at_ms(&self) -> u64 {
1503 self.issued_at_ms
1504 }
1505
1506 pub async fn simulate(self) -> Result<SimulatedIceProposal<'a>, IceError> {
1514 let snap = self.client.snapshot_reader.read();
1515 let blast = simulate_ice_proposal(&snap, &self.action);
1516 Ok(SimulatedIceProposal {
1517 client: self.client,
1518 action: self.action,
1519 issued_at_ms: self.issued_at_ms,
1520 blast,
1521 })
1522 }
1523}
1524
1525pub struct SimulatedIceProposal<'a> {
1538 client: &'a DeckClient,
1539 action: IceActionProposal,
1540 issued_at_ms: u64,
1541 blast: BlastRadius,
1542}
1543
1544impl<'a> SimulatedIceProposal<'a> {
1545 pub fn blast_radius(&self) -> &BlastRadius {
1547 &self.blast
1548 }
1549
1550 pub fn action(&self) -> &IceActionProposal {
1552 &self.action
1553 }
1554
1555 pub fn issued_at_ms(&self) -> u64 {
1559 self.issued_at_ms
1560 }
1561
1562 pub fn blast_hash(&self) -> super::meshos::BlastRadiusHash {
1567 super::meshos::blast_radius_hash(&self.blast)
1568 }
1569
1570 pub async fn commit(self, signatures: &[OperatorSignature]) -> Result<ChainCommit, IceError> {
1580 let blast_hash = self.blast_hash();
1581 let threshold = self.client.config.ice_signature_threshold;
1582 if signatures.len() < threshold {
1583 return Err(IceError::new(
1584 "insufficient_signatures",
1585 format!(
1586 "ICE commit requires {} operator signatures; got {}",
1587 threshold,
1588 signatures.len()
1589 ),
1590 ));
1591 }
1592 if let Some(registry) = self.client.operator_registry.as_ref() {
1593 let payload =
1600 ice_proposal_signing_payload(&self.action, self.issued_at_ms, &blast_hash);
1601 let mut unique_operators: std::collections::BTreeSet<u64> =
1602 std::collections::BTreeSet::new();
1603 for sig in signatures {
1604 registry
1605 .verify(sig, &payload)
1606 .map_err(verify_error_to_ice)?;
1607 unique_operators.insert(sig.operator_id);
1608 }
1609 if unique_operators.len() < threshold {
1613 return Err(IceError::new(
1614 "insufficient_signatures",
1615 format!(
1616 "ICE commit requires {} distinct operator signatures; got {} distinct",
1617 threshold,
1618 unique_operators.len()
1619 ),
1620 ));
1621 }
1622 let kind = self.action.kind();
1630 self.client
1631 .publish_signed_ice(
1632 self.action,
1633 signatures.to_vec(),
1634 self.issued_at_ms,
1635 blast_hash,
1636 kind,
1637 )
1638 .await
1639 } else {
1640 let kind = self.action.kind();
1649 let event = self.action.to_admin_event();
1650 self.client.publish_admin(event, kind).await
1651 }
1652 }
1653}
1654
1655pub struct AuditQuery<'a> {
1687 client: &'a DeckClient,
1688 limit: Option<usize>,
1689 operator_filter: Option<u64>,
1690 time_range: Option<(u64, u64)>,
1691 force_only: bool,
1692 since_seq: Option<u64>,
1693}
1694
1695impl<'a> AuditQuery<'a> {
1696 fn new(client: &'a DeckClient) -> Self {
1697 Self {
1698 client,
1699 limit: None,
1700 operator_filter: None,
1701 time_range: None,
1702 force_only: false,
1703 since_seq: None,
1704 }
1705 }
1706
1707 pub fn recent(mut self, limit: usize) -> Self {
1717 self.limit = Some(limit);
1718 self
1719 }
1720
1721 pub fn by_operator(mut self, op_id: u64) -> Self {
1725 self.operator_filter = Some(op_id);
1726 self
1727 }
1728
1729 pub fn between(mut self, start_ms: u64, end_ms: u64) -> Self {
1733 self.time_range = Some((start_ms, end_ms));
1734 self
1735 }
1736
1737 pub fn force_only(mut self) -> Self {
1742 self.force_only = true;
1743 self
1744 }
1745
1746 pub fn since(mut self, since_seq: u64) -> Self {
1762 self.since_seq = Some(since_seq);
1763 self
1764 }
1765
1766 pub fn collect(self) -> Vec<super::meshos::AdminAuditRecord> {
1771 let snap = self.client.snapshot_reader.read();
1772 let mut matched: Vec<super::meshos::AdminAuditRecord> = snap
1773 .admin_audit
1774 .iter()
1775 .filter(|r| {
1776 if let Some(since) = self.since_seq {
1777 if r.seq <= since {
1778 return false;
1779 }
1780 }
1781 if let Some(op_id) = self.operator_filter {
1782 if !r.operator_ids.contains(&op_id) {
1783 return false;
1784 }
1785 }
1786 if let Some((start, end)) = self.time_range {
1787 if r.committed_at_ms < start || r.committed_at_ms > end {
1788 return false;
1789 }
1790 }
1791 if self.force_only && !r.event.is_ice() {
1792 return false;
1793 }
1794 true
1795 })
1796 .cloned()
1797 .collect();
1798 matched.reverse();
1801 if let Some(limit) = self.limit {
1802 matched.truncate(limit);
1803 }
1804 matched
1805 }
1806
1807 pub fn stream(self) -> AuditStream {
1818 AuditStream::new(
1819 self.client.snapshot_reader.clone(),
1820 self.client.config.snapshot_poll_interval,
1821 AuditFilter {
1822 operator: self.operator_filter,
1823 time_range: self.time_range,
1824 force_only: self.force_only,
1825 },
1826 self.since_seq.unwrap_or(0),
1827 )
1828 }
1829}
1830
1831#[derive(Clone, Debug)]
1835struct AuditFilter {
1836 operator: Option<u64>,
1837 time_range: Option<(u64, u64)>,
1838 force_only: bool,
1839}
1840
1841impl AuditFilter {
1842 fn matches(&self, record: &super::meshos::AdminAuditRecord) -> bool {
1843 if let Some(op_id) = self.operator {
1844 if !record.operator_ids.contains(&op_id) {
1845 return false;
1846 }
1847 }
1848 if let Some((start, end)) = self.time_range {
1849 if record.committed_at_ms < start || record.committed_at_ms > end {
1850 return false;
1851 }
1852 }
1853 if self.force_only && !record.event.is_ice() {
1854 return false;
1855 }
1856 true
1857 }
1858}
1859
1860pub struct AuditStream {
1869 reader: super::meshos::MeshOsSnapshotReader,
1870 interval: Interval,
1871 filter: AuditFilter,
1872 last_seq: u64,
1873 queued: std::collections::VecDeque<super::meshos::AdminAuditRecord>,
1878}
1879
1880impl AuditStream {
1881 fn new(
1882 reader: super::meshos::MeshOsSnapshotReader,
1883 poll_interval: Duration,
1884 filter: AuditFilter,
1885 initial_seq_watermark: u64,
1886 ) -> Self {
1887 let poll_interval = poll_interval.max(Duration::from_millis(1));
1888 Self {
1889 reader,
1890 interval: interval(poll_interval),
1891 filter,
1892 last_seq: initial_seq_watermark,
1893 queued: std::collections::VecDeque::new(),
1894 }
1895 }
1896}
1897
1898#[inline]
1908fn rearm_after_empty_tick<T>(cx: &Context<'_>) -> Poll<Option<T>> {
1909 cx.waker().wake_by_ref();
1910 Poll::Pending
1911}
1912
1913impl Stream for AuditStream {
1914 type Item = Result<super::meshos::AdminAuditRecord, DeckError>;
1915
1916 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1917 if let Some(record) = self.queued.pop_front() {
1920 return Poll::Ready(Some(Ok(record)));
1921 }
1922 match self.interval.poll_tick(cx) {
1924 Poll::Ready(_) => {
1925 let snap = self.reader.read();
1926 let last_seq = self.last_seq;
1927 let mut max_seq = last_seq;
1929 for record in snap.admin_audit.iter().cloned() {
1930 if record.seq <= last_seq {
1931 continue;
1932 }
1933 if record.seq > max_seq {
1934 max_seq = record.seq;
1935 }
1936 if self.filter.matches(&record) {
1937 self.queued.push_back(record);
1938 }
1939 }
1940 self.last_seq = max_seq;
1941 if let Some(record) = self.queued.pop_front() {
1942 Poll::Ready(Some(Ok(record)))
1943 } else {
1944 rearm_after_empty_tick(cx)
1945 }
1946 }
1947 Poll::Pending => Poll::Pending,
1948 }
1949 }
1950}
1951
1952#[derive(Clone, Debug, Default)]
1955pub struct LogFilter {
1956 pub min_level: Option<super::meshos::LogLevel>,
1959 pub daemon_id: Option<u64>,
1963 pub node_id: Option<NodeId>,
1967 pub since_seq: Option<u64>,
1972}
1973
1974impl LogFilter {
1975 pub fn new() -> Self {
1977 Self::default()
1978 }
1979
1980 pub fn min_level(mut self, level: super::meshos::LogLevel) -> Self {
1982 self.min_level = Some(level);
1983 self
1984 }
1985
1986 pub fn with_daemon(mut self, daemon_id: u64) -> Self {
1988 self.daemon_id = Some(daemon_id);
1989 self
1990 }
1991
1992 pub fn with_node(mut self, node_id: NodeId) -> Self {
1994 self.node_id = Some(node_id);
1995 self
1996 }
1997
1998 pub fn since(mut self, since_seq: u64) -> Self {
2006 self.since_seq = Some(since_seq);
2007 self
2008 }
2009
2010 fn matches(&self, record: &super::meshos::LogRecord) -> bool {
2011 if let Some(min) = self.min_level {
2012 if record.level < min {
2013 return false;
2014 }
2015 }
2016 if let Some(id) = self.daemon_id {
2017 if record.daemon_id != Some(id) {
2018 return false;
2019 }
2020 }
2021 if let Some(node) = self.node_id {
2022 if record.node_id != Some(node) {
2023 return false;
2024 }
2025 }
2026 true
2027 }
2028}
2029
2030pub struct LogStream {
2035 reader: super::meshos::MeshOsSnapshotReader,
2036 interval: Interval,
2037 filter: LogFilter,
2038 last_seq: u64,
2039 queued: std::collections::VecDeque<super::meshos::LogRecord>,
2040}
2041
2042impl LogStream {
2043 fn new(
2044 reader: super::meshos::MeshOsSnapshotReader,
2045 poll_interval: Duration,
2046 filter: LogFilter,
2047 ) -> Self {
2048 let poll_interval = poll_interval.max(Duration::from_millis(1));
2049 let last_seq = filter.since_seq.unwrap_or(0);
2050 Self {
2051 reader,
2052 interval: interval(poll_interval),
2053 filter,
2054 last_seq,
2055 queued: std::collections::VecDeque::new(),
2056 }
2057 }
2058}
2059
2060impl Stream for LogStream {
2061 type Item = Result<super::meshos::LogRecord, DeckError>;
2062
2063 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
2064 if let Some(record) = self.queued.pop_front() {
2065 return Poll::Ready(Some(Ok(record)));
2066 }
2067 match self.interval.poll_tick(cx) {
2068 Poll::Ready(_) => {
2069 let snap = self.reader.read();
2070 let last_seq = self.last_seq;
2071 let mut max_seq = last_seq;
2072 for record in snap.log_ring.iter().cloned() {
2073 if record.seq <= last_seq {
2074 continue;
2075 }
2076 if record.seq > max_seq {
2077 max_seq = record.seq;
2078 }
2079 if self.filter.matches(&record) {
2080 self.queued.push_back(record);
2081 }
2082 }
2083 self.last_seq = max_seq;
2084 if let Some(record) = self.queued.pop_front() {
2085 Poll::Ready(Some(Ok(record)))
2086 } else {
2087 rearm_after_empty_tick(cx)
2088 }
2089 }
2090 Poll::Pending => Poll::Pending,
2091 }
2092 }
2093}
2094
2095pub struct FailureStream {
2107 reader: super::meshos::MeshOsSnapshotReader,
2108 interval: Interval,
2109 last_seq: u64,
2110 queued: std::collections::VecDeque<super::meshos::FailureRecord>,
2111}
2112
2113impl FailureStream {
2114 fn new(
2115 reader: super::meshos::MeshOsSnapshotReader,
2116 poll_interval: Duration,
2117 initial_seq_watermark: u64,
2118 ) -> Self {
2119 let poll_interval = poll_interval.max(Duration::from_millis(1));
2120 Self {
2121 reader,
2122 interval: interval(poll_interval),
2123 last_seq: initial_seq_watermark,
2124 queued: std::collections::VecDeque::new(),
2125 }
2126 }
2127}
2128
2129impl Stream for FailureStream {
2130 type Item = Result<super::meshos::FailureRecord, DeckError>;
2131
2132 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
2133 if let Some(record) = self.queued.pop_front() {
2134 return Poll::Ready(Some(Ok(record)));
2135 }
2136 match self.interval.poll_tick(cx) {
2137 Poll::Ready(_) => {
2138 let snap = self.reader.read();
2139 let last_seq = self.last_seq;
2140 let mut max_seq = last_seq;
2141 for record in snap.recent_failures.iter().cloned() {
2142 if record.seq <= last_seq {
2143 continue;
2144 }
2145 if record.seq > max_seq {
2146 max_seq = record.seq;
2147 }
2148 self.queued.push_back(record);
2149 }
2150 self.last_seq = max_seq;
2151 if let Some(record) = self.queued.pop_front() {
2152 Poll::Ready(Some(Ok(record)))
2153 } else {
2154 rearm_after_empty_tick(cx)
2155 }
2156 }
2157 Poll::Pending => Poll::Pending,
2158 }
2159 }
2160}
2161
2162type SharedSnapshotChangeRx = Arc<tokio::sync::Mutex<tokio::sync::watch::Receiver<u64>>>;
2169
2170fn next_snapshot_change(
2175 rx: SharedSnapshotChangeRx,
2176) -> Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
2177 Box::pin(async move {
2178 let mut guard = rx.lock().await;
2179 let _ = guard.changed().await;
2182 })
2183}
2184
2185pub struct SnapshotStream {
2193 reader: MeshOsSnapshotReader,
2194 ceiling: Interval,
2197 change_rx: SharedSnapshotChangeRx,
2201 pending: parking_lot::Mutex<Pin<Box<dyn std::future::Future<Output = ()> + Send>>>,
2207}
2208
2209impl SnapshotStream {
2210 fn new(reader: MeshOsSnapshotReader, poll_interval: Duration) -> Self {
2211 let poll_interval = poll_interval.max(Duration::from_millis(1));
2214 let change_rx: SharedSnapshotChangeRx =
2215 Arc::new(tokio::sync::Mutex::new(reader.subscribe_changes()));
2216 let pending = parking_lot::Mutex::new(next_snapshot_change(change_rx.clone()));
2217 Self {
2218 reader,
2219 ceiling: interval(poll_interval),
2220 change_rx,
2221 pending,
2222 }
2223 }
2224}
2225
2226impl Stream for SnapshotStream {
2227 type Item = Result<MeshOsSnapshot, DeckError>;
2228
2229 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
2230 let this = self.get_mut();
2233 let changed = {
2238 let mut pending = this.pending.lock();
2239 let ready = pending.as_mut().poll(cx).is_ready();
2240 if ready {
2241 *pending = next_snapshot_change(this.change_rx.clone());
2242 }
2243 ready
2244 };
2245 let ticked = this.ceiling.poll_tick(cx).is_ready();
2246 if changed || ticked {
2247 Poll::Ready(Some(Ok(this.reader.read())))
2248 } else {
2249 Poll::Pending
2250 }
2251 }
2252}
2253
2254pub struct StatusSummaryStream {
2263 reader: super::meshos::MeshOsSnapshotReader,
2264 ceiling: Interval,
2266 change_rx: SharedSnapshotChangeRx,
2269 pending: parking_lot::Mutex<Pin<Box<dyn std::future::Future<Output = ()> + Send>>>,
2274 last_emitted: Option<StatusSummary>,
2275}
2276
2277impl StatusSummaryStream {
2278 fn new(reader: super::meshos::MeshOsSnapshotReader, poll_interval: Duration) -> Self {
2279 let poll_interval = poll_interval.max(Duration::from_millis(1));
2280 let change_rx: SharedSnapshotChangeRx =
2281 Arc::new(tokio::sync::Mutex::new(reader.subscribe_changes()));
2282 let pending = parking_lot::Mutex::new(next_snapshot_change(change_rx.clone()));
2283 Self {
2284 reader,
2285 ceiling: interval(poll_interval),
2286 change_rx,
2287 pending,
2288 last_emitted: None,
2289 }
2290 }
2291}
2292
2293impl Stream for StatusSummaryStream {
2294 type Item = Result<StatusSummary, DeckError>;
2295
2296 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
2297 let this = self.get_mut();
2298 loop {
2305 let changed = {
2306 let mut pending = this.pending.lock();
2307 let ready = pending.as_mut().poll(cx).is_ready();
2308 if ready {
2309 *pending = next_snapshot_change(this.change_rx.clone());
2310 }
2311 ready
2312 };
2313 let ticked = this.ceiling.poll_tick(cx).is_ready();
2314 if !changed && !ticked {
2315 return Poll::Pending;
2316 }
2317 let summary = build_status_summary(&this.reader.read());
2318 let should_emit = match &this.last_emitted {
2319 None => true,
2320 Some(prev) => prev != &summary,
2321 };
2322 if should_emit {
2323 this.last_emitted = Some(summary.clone());
2324 return Poll::Ready(Some(Ok(summary)));
2325 }
2326 }
2328 }
2329}
2330
2331#[cfg(test)]
2332mod tests {
2333 use super::*;
2334 use crate::adapter::net::behavior::meshos::{
2335 LoggingDispatcher, MaintenanceTransition, MeshOsAction, MeshOsConfig,
2336 };
2337
2338 fn fast_config() -> MeshOsConfig {
2339 MeshOsConfig::default()
2340 .with_this_node(42)
2341 .with_tick_interval(Duration::from_millis(10))
2342 .with_event_queue_capacity(64)
2343 .with_action_queue_capacity(64)
2344 }
2345
2346 #[tokio::test]
2347 async fn operator_identity_id_matches_keypair_origin_hash() {
2348 let kp = EntityKeypair::generate();
2349 let origin = kp.origin_hash();
2350 let identity = OperatorIdentity::from_keypair(kp);
2351 assert_eq!(identity.operator_id(), origin);
2352 }
2353
2354 #[tokio::test]
2355 async fn deck_subnet_and_gateway_accessors_default_to_empty_without_mesh() {
2356 let dispatcher = Arc::new(LoggingDispatcher::new());
2362 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2363 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
2364 assert_eq!(deck.local_subnet(), None);
2365 assert!(deck.known_subnets().is_empty());
2366 assert!(deck.gateway_stats().is_none());
2367 assert!(deck.gateway_exports().is_empty());
2368 assert_eq!(deck.channel_visibility("any/name"), None);
2369 assert!(deck.channels().is_empty());
2370 assert_eq!(deck.channel_wire_hash("any/name"), None);
2371 let _ = runtime.shutdown().await;
2372 }
2373
2374 #[tokio::test]
2375 async fn deck_with_mesh_surfaces_local_subnet_and_gateway_stats() {
2376 use crate::adapter::net::{
2382 ChannelConfig, ChannelConfigRegistry, ChannelId, MeshNodeConfig, SubnetId, Visibility,
2383 };
2384 use std::net::SocketAddr;
2385
2386 let dispatcher = Arc::new(LoggingDispatcher::new());
2387 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2388
2389 let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
2390 let mut mesh_cfg = MeshNodeConfig::new(addr, [0x17u8; 32]);
2391 mesh_cfg = mesh_cfg.with_subnet(SubnetId::new(&[3, 7]));
2392 let mut mesh = crate::adapter::net::MeshNode::new(EntityKeypair::generate(), mesh_cfg)
2393 .await
2394 .expect("MeshNode::new");
2395 let registry = Arc::new(ChannelConfigRegistry::new());
2396 let metrics_id = ChannelId::parse("internal/metrics").expect("channel id");
2397 registry.insert(
2398 ChannelConfig::new(metrics_id.clone()).with_visibility(Visibility::SubnetLocal),
2399 );
2400 mesh.set_channel_configs(registry);
2401 let mesh = Arc::new(mesh);
2402
2403 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate())
2404 .with_mesh(mesh.clone());
2405
2406 assert_eq!(deck.local_subnet(), Some(SubnetId::new(&[3, 7])));
2407 let stats = deck.gateway_stats().expect("gateway installed");
2408 assert_eq!(stats.local_subnet, SubnetId::new(&[3, 7]));
2409 assert_eq!(stats.forwarded, 0);
2410 assert_eq!(stats.dropped, 0);
2411 assert_eq!(stats.export_rules, 0);
2412 assert!(stats.peer_subnets.is_empty());
2413
2414 assert_eq!(
2417 deck.channel_visibility("internal/metrics"),
2418 Some(Visibility::SubnetLocal),
2419 );
2420 let channels = deck.channels();
2422 assert_eq!(channels.len(), 1);
2423 assert_eq!(channels[0].0, "internal/metrics");
2424 assert_eq!(channels[0].1, Visibility::SubnetLocal);
2425 assert_eq!(
2427 deck.channel_wire_hash("internal/metrics"),
2428 Some(metrics_id.wire_hash()),
2429 );
2430 assert_eq!(
2431 deck.channel_canonical_hash("internal/metrics"),
2432 Some(metrics_id.hash()),
2433 );
2434
2435 let _ = runtime.shutdown().await;
2436 }
2437
2438 #[tokio::test]
2439 async fn deck_error_display_carries_kind_discriminator() {
2440 let err = DeckError::new("unknown_node", "node 99 is not in the cluster");
2441 let rendered = err.to_string();
2442 assert!(
2443 rendered.contains("<<deck-sdk-kind:unknown_node>>"),
2444 "expected discriminator envelope, got {rendered:?}",
2445 );
2446 }
2447
2448 #[tokio::test]
2449 async fn admin_enter_maintenance_publishes_admin_event_and_returns_commit() {
2450 let dispatcher = Arc::new(LoggingDispatcher::new());
2456 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2457 let identity = OperatorIdentity::generate();
2458 let deck = DeckClient::from_runtime(&runtime, identity.clone());
2459 let commit = deck
2460 .admin()
2461 .enter_maintenance(42, None)
2462 .await
2463 .expect("commit");
2464 assert_eq!(commit.operator_id(), identity.operator_id());
2465 assert_eq!(commit.event_kind(), "enter_maintenance");
2466 assert!(commit.commit_id() >= 1);
2467
2468 tokio::time::sleep(Duration::from_millis(80)).await;
2473 let snap = runtime.snapshot();
2474 assert!(
2475 !matches!(
2476 snap.local_maintenance,
2477 crate::adapter::net::behavior::meshos::MaintenanceStateSnapshot::Active
2478 ),
2479 "local maintenance should have transitioned out of Active, got {:?}",
2480 snap.local_maintenance,
2481 );
2482
2483 let _ = runtime.shutdown().await;
2484 }
2485
2486 #[tokio::test]
2487 async fn admin_drop_replicas_publishes_with_supplied_chain_ids() {
2488 let dispatcher = Arc::new(LoggingDispatcher::new());
2489 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2490 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
2491 let commit = deck
2492 .admin()
2493 .drop_replicas(42, vec![1, 2, 3])
2494 .await
2495 .expect("commit");
2496 assert_eq!(commit.event_kind(), "drop_replicas");
2497 let _ = runtime.shutdown().await;
2498 }
2499
2500 #[tokio::test]
2501 async fn commit_ids_increment_monotonically_per_client() {
2502 let dispatcher = Arc::new(LoggingDispatcher::new());
2503 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2504 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
2505 let a = deck.admin().cordon(42).await.unwrap();
2506 let b = deck.admin().uncordon(42).await.unwrap();
2507 assert!(b.commit_id() > a.commit_id());
2508 let _ = runtime.shutdown().await;
2509 }
2510
2511 #[tokio::test]
2512 async fn snapshot_stream_yields_a_snapshot_per_poll_interval() {
2513 use futures::StreamExt;
2514 let dispatcher = Arc::new(LoggingDispatcher::new());
2515 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2516 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
2517 DeckClientConfig {
2518 snapshot_poll_interval: Duration::from_millis(20),
2519 ..DeckClientConfig::default()
2520 },
2521 );
2522
2523 let mut stream = deck.snapshots();
2524 let first = stream.next().await.expect("first").expect("ok");
2528 let second = stream.next().await.expect("second").expect("ok");
2529 assert_eq!(first.local_maintenance, second.local_maintenance);
2531 let _ = runtime.shutdown().await;
2532 }
2533
2534 #[tokio::test]
2535 async fn snapshot_stream_observes_admin_command_aftermath() {
2536 use futures::StreamExt;
2541 let dispatcher = Arc::new(LoggingDispatcher::new());
2542 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2543 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
2544 DeckClientConfig {
2545 snapshot_poll_interval: Duration::from_millis(15),
2546 ..DeckClientConfig::default()
2547 },
2548 );
2549
2550 let _ = deck.admin().enter_maintenance(42, None).await.unwrap();
2551
2552 let mut stream = deck.snapshots();
2555 let mut saw_transition = false;
2556 for _ in 0..20 {
2557 let snap = stream.next().await.expect("next").expect("ok");
2558 if !matches!(
2559 snap.local_maintenance,
2560 crate::adapter::net::behavior::meshos::MaintenanceStateSnapshot::Active
2561 ) {
2562 saw_transition = true;
2563 break;
2564 }
2565 }
2566 assert!(
2567 saw_transition,
2568 "stream should have surfaced a non-Active local_maintenance after enter_maintenance",
2569 );
2570 let _ = runtime.shutdown().await;
2571 }
2572
2573 #[tokio::test]
2574 async fn change_signal_stays_quiet_on_idle_ticks_and_fires_on_structural_change() {
2575 let dispatcher = Arc::new(LoggingDispatcher::new());
2582 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2583 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
2584
2585 let mut rx = deck.snapshot_reader.subscribe_changes();
2586 tokio::time::sleep(Duration::from_millis(120)).await;
2589 rx.borrow_and_update();
2590
2591 tokio::time::sleep(Duration::from_millis(200)).await;
2595 assert!(
2596 !rx.has_changed().unwrap(),
2597 "change signal fired on idle ticks — per-tick time progression \
2598 must NOT count as a structural change",
2599 );
2600
2601 let p = deck
2604 .ice()
2605 .freeze_cluster(Duration::from_secs(15))
2606 .simulate()
2607 .await
2608 .expect("simulate");
2609 let sig = deck
2610 .identity()
2611 .sign_proposal(p.action(), p.issued_at_ms(), &p.blast_hash());
2612 p.commit(&[sig]).await.expect("commit");
2613
2614 tokio::time::timeout(Duration::from_secs(2), rx.changed())
2615 .await
2616 .expect("a structural change must fire the signal well inside the timeout")
2617 .expect("change sender alive");
2618
2619 rx.borrow_and_update();
2622 tokio::time::sleep(Duration::from_millis(200)).await;
2623 assert!(
2624 !rx.has_changed().unwrap(),
2625 "freeze countdown advancing must not bump the change generation",
2626 );
2627
2628 let _ = runtime.shutdown().await;
2629 }
2630
2631 #[tokio::test]
2632 async fn admin_commit_after_runtime_shutdown_returns_loop_closed_error() {
2633 let dispatcher = Arc::new(LoggingDispatcher::new());
2634 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2635 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
2636 let _ = runtime.shutdown().await;
2637 let err = deck
2642 .admin()
2643 .cordon(42)
2644 .await
2645 .expect_err("publish after shutdown should fail");
2646 assert_eq!(err.kind, "loop_closed");
2647 }
2648
2649 #[allow(dead_code)]
2652 fn _ensure_action_types_are_in_scope() -> (MaintenanceTransition, MeshOsAction) {
2653 (
2654 MaintenanceTransition::EnteringMaintenance,
2655 MeshOsAction::CommitMaintenanceTransition {
2656 node: 0,
2657 target: MaintenanceTransition::EnteringMaintenance,
2658 },
2659 )
2660 }
2661
2662 #[tokio::test]
2670 async fn ice_proposal_commit_with_insufficient_signatures_fails() {
2671 let dispatcher = Arc::new(LoggingDispatcher::new());
2672 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2673 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
2675 DeckClientConfig {
2676 snapshot_poll_interval: Duration::from_millis(100),
2677 ice_signature_threshold: 2,
2678 },
2679 );
2680 let proposal = deck.ice().freeze_cluster(Duration::from_secs(10));
2681 let simulated = proposal.simulate().await.expect("simulate");
2682 let sig = deck.identity().sign_proposal(
2683 simulated.action(),
2684 simulated.issued_at_ms(),
2685 &simulated.blast_hash(),
2686 );
2687 let err = simulated
2688 .commit(&[sig])
2689 .await
2690 .expect_err("under-threshold commit should fail");
2691 assert_eq!(err.kind, "insufficient_signatures");
2692 let _ = runtime.shutdown().await;
2693 }
2694
2695 #[tokio::test]
2696 async fn ice_freeze_proposal_simulate_then_commit_lands_freeze_on_loop() {
2697 let dispatcher = Arc::new(LoggingDispatcher::new());
2698 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2699 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
2700
2701 let proposal = deck.ice().freeze_cluster(Duration::from_secs(30));
2702 let simulated = proposal.simulate().await.expect("simulate");
2703 assert_eq!(
2705 simulated.blast_radius().estimated_drain_delay,
2706 Some(Duration::from_secs(30))
2707 );
2708 let sig = deck.identity().sign_proposal(
2709 simulated.action(),
2710 simulated.issued_at_ms(),
2711 &simulated.blast_hash(),
2712 );
2713 let commit = simulated.commit(&[sig]).await.expect("commit");
2714 assert_eq!(commit.event_kind(), "freeze_cluster");
2715
2716 tokio::time::sleep(Duration::from_millis(80)).await;
2719 let snap = runtime.snapshot();
2720 assert!(
2721 snap.freeze_remaining_ms.is_some(),
2722 "freeze_remaining_ms should be set after committed freeze",
2723 );
2724 let _ = runtime.shutdown().await;
2725 }
2726
2727 #[tokio::test]
2728 async fn ice_thaw_proposal_simulate_warns_no_op_when_unfrozen() {
2729 let dispatcher = Arc::new(LoggingDispatcher::new());
2730 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2731 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
2732
2733 let proposal = deck.ice().thaw_cluster();
2734 let simulated = proposal.simulate().await.expect("simulate");
2735 assert!(simulated.blast_radius().warnings.iter().any(|w| matches!(
2737 w,
2738 crate::adapter::net::behavior::meshos::BlastWarning::ThawHasNoFreezeToCancel
2739 )));
2740 let _ = runtime.shutdown().await;
2741 }
2742
2743 const TEST_BLAST_HASH: super::super::meshos::BlastRadiusHash =
2748 [1u8; super::super::meshos::BLAST_RADIUS_HASH_LEN];
2749
2750 fn _assert_proposal_send_sync_static_check() {
2758 fn _assert_send<T: Send>() {}
2759 fn _assert_send_sync<T: Send + Sync>() {}
2760 _assert_send_sync::<IceProposal<'static>>();
2761 _assert_send_sync::<SimulatedIceProposal<'static>>();
2762 _assert_send::<SnapshotStream>();
2763 _assert_send::<StatusSummaryStream>();
2764 _assert_send::<AuditStream>();
2765 _assert_send::<LogStream>();
2766 _assert_send::<FailureStream>();
2767 }
2768
2769 #[tokio::test]
2770 async fn operator_signature_carries_issuing_operator_id() {
2771 let identity = OperatorIdentity::generate();
2772 let proposal = IceActionProposal::FreezeCluster {
2773 ttl: Duration::from_secs(60),
2774 };
2775 let sig = identity.sign_proposal(
2776 &proposal,
2777 super::super::meshos::now_ms_since_unix_epoch(),
2778 &TEST_BLAST_HASH,
2779 );
2780 assert_eq!(sig.operator_id, identity.operator_id());
2781 assert_eq!(sig.signature.len(), 64);
2783 }
2784
2785 #[tokio::test]
2786 async fn operator_registry_verifies_a_well_formed_signature() {
2787 let identity = OperatorIdentity::generate();
2788 let mut registry = OperatorRegistry::new();
2789 registry.register(identity.keypair());
2790
2791 let proposal = IceActionProposal::FreezeCluster {
2792 ttl: Duration::from_secs(60),
2793 };
2794 let ts = super::super::meshos::now_ms_since_unix_epoch();
2795 let sig = identity.sign_proposal(&proposal, ts, &TEST_BLAST_HASH);
2796 let payload = ice_proposal_signing_payload(&proposal, ts, &TEST_BLAST_HASH);
2797 registry.verify(&sig, &payload).expect("valid signature");
2798 }
2799
2800 #[tokio::test]
2801 async fn operator_registry_rejects_unknown_operator() {
2802 let registry = OperatorRegistry::new();
2803 let identity = OperatorIdentity::generate();
2804 let proposal = IceActionProposal::ThawCluster;
2805 let ts = super::super::meshos::now_ms_since_unix_epoch();
2806 let sig = identity.sign_proposal(&proposal, ts, &TEST_BLAST_HASH);
2807 let payload = ice_proposal_signing_payload(&proposal, ts, &TEST_BLAST_HASH);
2808 let err = registry
2809 .verify(&sig, &payload)
2810 .expect_err("unregistered operator should not verify");
2811 assert_eq!(err.kind(), "not_authorized");
2812 }
2813
2814 #[tokio::test]
2815 async fn operator_registry_rejects_tampered_signature_bytes() {
2816 let identity = OperatorIdentity::generate();
2817 let mut registry = OperatorRegistry::new();
2818 registry.register(identity.keypair());
2819
2820 let proposal = IceActionProposal::FreezeCluster {
2821 ttl: Duration::from_secs(10),
2822 };
2823 let ts = super::super::meshos::now_ms_since_unix_epoch();
2824 let mut sig = identity.sign_proposal(&proposal, ts, &TEST_BLAST_HASH);
2825 sig.signature[0] ^= 0x01;
2827 let payload = ice_proposal_signing_payload(&proposal, ts, &TEST_BLAST_HASH);
2828 let err = registry
2829 .verify(&sig, &payload)
2830 .expect_err("tampered signature should not verify");
2831 assert_eq!(err.kind(), "signature_invalid");
2832 }
2833
2834 #[tokio::test]
2835 async fn operator_registry_rejects_signature_for_wrong_payload() {
2836 let identity = OperatorIdentity::generate();
2841 let mut registry = OperatorRegistry::new();
2842 registry.register(identity.keypair());
2843
2844 let signed_proposal = IceActionProposal::FreezeCluster {
2845 ttl: Duration::from_secs(10),
2846 };
2847 let other_proposal = IceActionProposal::FreezeCluster {
2848 ttl: Duration::from_secs(60),
2849 };
2850 let ts = super::super::meshos::now_ms_since_unix_epoch();
2851 let sig = identity.sign_proposal(&signed_proposal, ts, &TEST_BLAST_HASH);
2852 let payload = ice_proposal_signing_payload(&other_proposal, ts, &TEST_BLAST_HASH);
2853 let err = registry
2854 .verify(&sig, &payload)
2855 .expect_err("cross-proposal signature should not verify");
2856 assert_eq!(err.kind(), "signature_invalid");
2857 }
2858
2859 #[tokio::test]
2860 async fn operator_registry_rejects_wrong_length_signature() {
2861 let identity = OperatorIdentity::generate();
2862 let mut registry = OperatorRegistry::new();
2863 registry.register(identity.keypair());
2864
2865 let proposal = IceActionProposal::ThawCluster;
2866 let sig = OperatorSignature {
2867 operator_id: identity.operator_id(),
2868 signature: vec![0; 32], };
2870 let payload = ice_proposal_signing_payload(
2871 &proposal,
2872 super::super::meshos::now_ms_since_unix_epoch(),
2873 &TEST_BLAST_HASH,
2874 );
2875 let err = registry
2876 .verify(&sig, &payload)
2877 .expect_err("wrong-length signature should not verify");
2878 assert_eq!(err.kind(), "signature_invalid");
2879 }
2880
2881 #[tokio::test]
2882 async fn ice_commit_with_registry_rejects_an_unverified_signature() {
2883 let dispatcher = Arc::new(LoggingDispatcher::new());
2887 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2888 let op_a = OperatorIdentity::generate();
2889 let op_b = OperatorIdentity::generate();
2890 let mut registry = OperatorRegistry::new();
2891 registry.register(op_a.keypair());
2892 registry.register(op_b.keypair());
2893 let deck = DeckClient::new(
2894 runtime.handle_clone(),
2895 runtime.snapshot_reader().clone(),
2896 op_a.clone(),
2897 DeckClientConfig {
2898 snapshot_poll_interval: Duration::from_millis(100),
2899 ice_signature_threshold: 2,
2900 },
2901 )
2902 .with_operator_registry(registry);
2903
2904 let proposal = deck.ice().freeze_cluster(Duration::from_secs(15));
2905 let simulated = proposal.simulate().await.expect("simulate");
2906 let sig_a = op_a.sign_proposal(
2907 simulated.action(),
2908 simulated.issued_at_ms(),
2909 &simulated.blast_hash(),
2910 );
2911 let mut sig_b = op_b.sign_proposal(
2912 simulated.action(),
2913 simulated.issued_at_ms(),
2914 &simulated.blast_hash(),
2915 );
2916 sig_b.signature[3] ^= 0xFF; let err = simulated
2919 .commit(&[sig_a, sig_b])
2920 .await
2921 .expect_err("commit with tampered sig should fail");
2922 assert_eq!(err.kind, "signature_invalid");
2923 let _ = runtime.shutdown().await;
2924 }
2925
2926 #[tokio::test]
2927 async fn ice_flush_avoid_lists_proposal_simulate_and_commit_round_trips() {
2928 use super::super::meshos::AvoidScope;
2929 let dispatcher = Arc::new(LoggingDispatcher::new());
2930 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2931 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
2932 let proposal = deck.ice().flush_avoid_lists(AvoidScope::OnPeer { peer: 5 });
2933 let simulated = proposal.simulate().await.expect("simulate");
2934 assert!(simulated.blast_radius().warnings.iter().any(|w| matches!(
2938 w,
2939 crate::adapter::net::behavior::meshos::BlastWarning::AvoidFlushRecoversPeer { peer: 5 }
2940 )));
2941 let sig = deck.identity().sign_proposal(
2943 simulated.action(),
2944 simulated.issued_at_ms(),
2945 &simulated.blast_hash(),
2946 );
2947 let commit = simulated.commit(&[sig]).await.expect("commit");
2948 assert_eq!(commit.event_kind(), "flush_avoid_lists");
2949 let _ = runtime.shutdown().await;
2950 }
2951
2952 #[tokio::test]
2953 async fn status_summary_stream_emits_initial_summary_immediately() {
2954 use futures::StreamExt;
2955 let dispatcher = Arc::new(LoggingDispatcher::new());
2956 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2957 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
2958 DeckClientConfig {
2959 snapshot_poll_interval: Duration::from_millis(10),
2960 ..DeckClientConfig::default()
2961 },
2962 );
2963 let mut stream = deck.status_summary_stream();
2964 let first = tokio::time::timeout(Duration::from_secs(2), stream.next())
2965 .await
2966 .expect("first timed out")
2967 .expect("first closed")
2968 .expect("first ok");
2969 assert!(first.freeze_remaining_ms.is_none());
2971 assert!(!first.local_maintenance_active);
2972 let _ = runtime.shutdown().await;
2973 }
2974
2975 #[tokio::test]
2976 async fn status_summary_stream_dedups_unchanged_summaries() {
2977 use futures::StreamExt;
2978 let dispatcher = Arc::new(LoggingDispatcher::new());
2979 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
2980 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
2981 DeckClientConfig {
2982 snapshot_poll_interval: Duration::from_millis(10),
2983 ..DeckClientConfig::default()
2984 },
2985 );
2986 let mut stream = deck.status_summary_stream();
2987 let _ = tokio::time::timeout(Duration::from_secs(2), stream.next())
2989 .await
2990 .expect("first")
2991 .expect("closed")
2992 .expect("ok");
2993 let second = tokio::time::timeout(Duration::from_millis(80), stream.next()).await;
2995 assert!(
2996 second.is_err(),
2997 "stream should not re-emit unchanged summary"
2998 );
2999 let _ = runtime.shutdown().await;
3000 }
3001
3002 #[tokio::test]
3003 async fn status_summary_stream_re_emits_on_freeze_state_change() {
3004 use futures::StreamExt;
3005 let dispatcher = Arc::new(LoggingDispatcher::new());
3006 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3007 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3008 DeckClientConfig {
3009 snapshot_poll_interval: Duration::from_millis(10),
3010 ..DeckClientConfig::default()
3011 },
3012 );
3013 let mut stream = deck.status_summary_stream();
3014 let first = tokio::time::timeout(Duration::from_secs(2), stream.next())
3015 .await
3016 .expect("first")
3017 .expect("closed")
3018 .expect("ok");
3019 assert!(first.freeze_remaining_ms.is_none());
3020
3021 let p = deck
3025 .ice()
3026 .freeze_cluster(Duration::from_secs(30))
3027 .simulate()
3028 .await
3029 .expect("simulate");
3030 let sig = deck
3031 .identity()
3032 .sign_proposal(p.action(), p.issued_at_ms(), &p.blast_hash());
3033 p.commit(&[sig]).await.expect("freeze");
3034 let after_freeze = tokio::time::timeout(Duration::from_secs(2), stream.next())
3035 .await
3036 .expect("after_freeze timed out")
3037 .expect("after_freeze closed")
3038 .expect("after_freeze ok");
3039 assert!(after_freeze.freeze_remaining_ms.is_some());
3040 assert!(after_freeze.admin_audit_ring_depth >= 1);
3041 let _ = runtime.shutdown().await;
3042 }
3043
3044 #[tokio::test]
3045 async fn status_summary_reflects_steady_state_idle_cluster() {
3046 let dispatcher = Arc::new(LoggingDispatcher::new());
3047 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3048 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3049 let summary = deck.status_summary();
3050 assert_eq!(summary.peers, PeerCounts::default());
3051 assert_eq!(summary.daemons, DaemonCounts::default());
3052 assert_eq!(summary.replica_chains, 0);
3053 assert_eq!(summary.recently_emitted_count, 0);
3054 assert!(summary.freeze_remaining_ms.is_none());
3055 assert!(!summary.local_maintenance_active);
3056 let _ = runtime.shutdown().await;
3057 }
3058
3059 #[tokio::test]
3060 async fn status_summary_flags_freeze_after_freeze_cluster_commit() {
3061 let dispatcher = Arc::new(LoggingDispatcher::new());
3062 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3063 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3064 let p = deck
3065 .ice()
3066 .freeze_cluster(Duration::from_secs(30))
3067 .simulate()
3068 .await
3069 .expect("simulate");
3070 let sig = deck
3071 .identity()
3072 .sign_proposal(p.action(), p.issued_at_ms(), &p.blast_hash());
3073 p.commit(&[sig]).await.expect("freeze");
3074 tokio::time::sleep(Duration::from_millis(60)).await;
3075 let summary = deck.status_summary();
3076 assert!(summary.freeze_remaining_ms.is_some());
3077 assert!(summary.admin_audit_ring_depth >= 1);
3079 let _ = runtime.shutdown().await;
3080 }
3081
3082 #[tokio::test]
3083 async fn status_summary_flags_local_maintenance_after_enter_maintenance() {
3084 let dispatcher = Arc::new(LoggingDispatcher::new());
3085 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3086 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3087 deck.admin()
3089 .enter_maintenance(42, None)
3090 .await
3091 .expect("commit");
3092 tokio::time::sleep(Duration::from_millis(60)).await;
3093 let summary = deck.status_summary();
3094 assert!(
3095 summary.local_maintenance_active,
3096 "local_maintenance_active should flip on after enter_maintenance",
3097 );
3098 let _ = runtime.shutdown().await;
3099 }
3100
3101 #[tokio::test]
3102 async fn subscribe_failures_yields_seeded_dispatcher_rejection() {
3103 use crate::adapter::net::behavior::meshos::DispatchError;
3104 use futures::StreamExt;
3105 let dispatcher = Arc::new(LoggingDispatcher::new());
3106 dispatcher.fail_next(DispatchError::drop("first"));
3107 let runtime = MeshOsRuntime::start(fast_config(), Arc::clone(&dispatcher));
3108 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3109 DeckClientConfig {
3110 snapshot_poll_interval: Duration::from_millis(15),
3111 ..DeckClientConfig::default()
3112 },
3113 );
3114 let mut stream = deck.subscribe_failures(0);
3115
3116 deck.admin().enter_maintenance(42, None).await.unwrap();
3117
3118 let record = tokio::time::timeout(Duration::from_secs(2), stream.next())
3119 .await
3120 .expect("timed out")
3121 .expect("closed")
3122 .expect("ok");
3123 assert!(record.seq > 0);
3126 assert!(record.reason.contains("first"));
3127 let _ = runtime.shutdown().await;
3128 }
3129
3130 #[tokio::test]
3131 async fn subscribe_failures_since_seq_drops_already_seen() {
3132 use crate::adapter::net::behavior::meshos::DispatchError;
3133 use futures::StreamExt;
3134 let dispatcher = Arc::new(LoggingDispatcher::new());
3135 dispatcher.fail_next(DispatchError::drop("first"));
3136 let runtime = MeshOsRuntime::start(fast_config(), Arc::clone(&dispatcher));
3137 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3138 DeckClientConfig {
3139 snapshot_poll_interval: Duration::from_millis(15),
3140 ..DeckClientConfig::default()
3141 },
3142 );
3143
3144 deck.admin().enter_maintenance(42, None).await.unwrap();
3145 let deadline = std::time::Instant::now() + Duration::from_secs(2);
3147 let mut seq_seen = 0u64;
3148 while std::time::Instant::now() < deadline {
3149 let all = deck.recent_failures();
3150 if let Some(r) = all.last() {
3151 seq_seen = r.seq;
3152 break;
3153 }
3154 tokio::time::sleep(Duration::from_millis(20)).await;
3155 }
3156 assert!(seq_seen > 0);
3157
3158 let mut stream = deck.subscribe_failures(seq_seen);
3161 let parked = tokio::time::timeout(Duration::from_millis(60), stream.next()).await;
3162 assert!(parked.is_err(), "no new failures means parked stream");
3163 let _ = runtime.shutdown().await;
3164 }
3165
3166 #[tokio::test]
3167 async fn recent_failures_surfaces_dispatcher_rejections() {
3168 use crate::adapter::net::behavior::meshos::DispatchError;
3169 let dispatcher = Arc::new(LoggingDispatcher::new());
3170 dispatcher.fail_next(DispatchError::drop("synthetic rejection"));
3171 let runtime = MeshOsRuntime::start(fast_config(), Arc::clone(&dispatcher));
3172 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3173
3174 deck.admin()
3178 .enter_maintenance(42, None)
3179 .await
3180 .expect("commit");
3181
3182 let deadline = std::time::Instant::now() + Duration::from_secs(2);
3184 let mut got: Vec<crate::adapter::net::behavior::meshos::FailureRecord> = Vec::new();
3185 while std::time::Instant::now() < deadline {
3186 got = deck.recent_failures();
3187 if !got.is_empty() {
3188 break;
3189 }
3190 tokio::time::sleep(Duration::from_millis(20)).await;
3191 }
3192 assert!(
3193 !got.is_empty(),
3194 "recent_failures should reflect the seeded dispatcher rejection",
3195 );
3196 assert!(got[0].reason.contains("synthetic rejection"));
3197 let _ = runtime.shutdown().await;
3198 }
3199
3200 #[tokio::test]
3201 async fn recent_failures_since_drops_records_at_or_below_cutoff() {
3202 use crate::adapter::net::behavior::meshos::DispatchError;
3203 let dispatcher = Arc::new(LoggingDispatcher::new());
3204 dispatcher.fail_next(DispatchError::drop("first failure"));
3205 let runtime = MeshOsRuntime::start(fast_config(), Arc::clone(&dispatcher));
3206 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3207
3208 deck.admin()
3209 .enter_maintenance(42, None)
3210 .await
3211 .expect("commit");
3212 let deadline = std::time::Instant::now() + Duration::from_secs(2);
3213 let mut all: Vec<crate::adapter::net::behavior::meshos::FailureRecord> = Vec::new();
3214 while std::time::Instant::now() < deadline {
3215 all = deck.recent_failures();
3216 if !all.is_empty() {
3217 break;
3218 }
3219 tokio::time::sleep(Duration::from_millis(20)).await;
3220 }
3221 assert!(!all.is_empty(), "seed failure should land");
3222
3223 let cutoff = all[0].recorded_at_ms;
3226 let after = deck.recent_failures_since(cutoff);
3227 assert!(
3228 after.iter().all(|r| r.recorded_at_ms > cutoff),
3229 "since filter should drop records at the cutoff",
3230 );
3231 assert!(after.iter().all(|r| r.reason != "first failure"));
3234 let _ = runtime.shutdown().await;
3235 }
3236
3237 #[tokio::test]
3238 async fn per_field_accessors_match_full_snapshot_contents() {
3239 let dispatcher = Arc::new(LoggingDispatcher::new());
3240 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3241 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3242
3243 let snap = deck.status();
3244 assert_eq!(deck.peers(), snap.peers);
3245 assert_eq!(deck.daemons(), snap.daemons);
3246 assert_eq!(deck.replicas(), snap.replicas);
3247 assert_eq!(deck.local_maintenance(), snap.local_maintenance);
3248 assert_eq!(deck.freeze_remaining_ms(), snap.freeze_remaining_ms);
3249 let _ = runtime.shutdown().await;
3250 }
3251
3252 #[tokio::test]
3253 async fn status_returns_freshest_snapshot_synchronously() {
3254 let dispatcher = Arc::new(LoggingDispatcher::new());
3255 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3256 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3257
3258 let s = deck.status();
3260 assert!(matches!(
3261 s.local_maintenance,
3262 crate::adapter::net::behavior::meshos::MaintenanceStateSnapshot::Active
3263 ));
3264
3265 let p = deck
3268 .ice()
3269 .freeze_cluster(Duration::from_secs(20))
3270 .simulate()
3271 .await
3272 .expect("simulate");
3273 let sig = deck
3274 .identity()
3275 .sign_proposal(p.action(), p.issued_at_ms(), &p.blast_hash());
3276 p.commit(&[sig]).await.expect("commit");
3277 tokio::time::sleep(Duration::from_millis(60)).await;
3278 let s = deck.status();
3279 assert!(s.freeze_remaining_ms.is_some());
3280 let _ = runtime.shutdown().await;
3281 }
3282
3283 #[tokio::test]
3284 async fn watch_resolves_immediately_when_predicate_already_true() {
3285 let dispatcher = Arc::new(LoggingDispatcher::new());
3286 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3287 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3288 let snap = tokio::time::timeout(
3291 Duration::from_millis(50),
3292 deck.watch(|s| s.freeze_remaining_ms.is_none()),
3293 )
3294 .await
3295 .expect("watch should not block when predicate already holds");
3296 assert!(snap.freeze_remaining_ms.is_none());
3297 let _ = runtime.shutdown().await;
3298 }
3299
3300 #[tokio::test]
3301 async fn watch_resolves_when_predicate_becomes_true_after_admin_commit() {
3302 let dispatcher = Arc::new(LoggingDispatcher::new());
3303 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3304 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3305 DeckClientConfig {
3306 snapshot_poll_interval: Duration::from_millis(10),
3307 ..DeckClientConfig::default()
3308 },
3309 );
3310
3311 let deck_handle = deck.snapshot_reader.clone();
3313 let watcher = {
3314 let identity = deck.identity().clone();
3315 let config = deck.config.clone();
3316 let handle = deck.handle.clone();
3317 let client = DeckClient::new(handle, deck_handle.clone(), identity, config);
3323 tokio::spawn(async move { client.watch(|s| s.freeze_remaining_ms.is_some()).await })
3324 };
3325
3326 tokio::time::sleep(Duration::from_millis(40)).await;
3328 let p = deck
3329 .ice()
3330 .freeze_cluster(Duration::from_secs(15))
3331 .simulate()
3332 .await
3333 .expect("simulate");
3334 let sig = deck
3335 .identity()
3336 .sign_proposal(p.action(), p.issued_at_ms(), &p.blast_hash());
3337 p.commit(&[sig]).await.expect("commit");
3338
3339 let snap = tokio::time::timeout(Duration::from_secs(2), watcher)
3340 .await
3341 .expect("watcher should resolve")
3342 .expect("join");
3343 assert!(snap.freeze_remaining_ms.is_some());
3344 let _ = runtime.shutdown().await;
3345 }
3346
3347 #[tokio::test]
3348 async fn watch_is_event_driven_resolving_far_under_the_poll_ceiling() {
3349 let dispatcher = Arc::new(LoggingDispatcher::new());
3355 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3356 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3357 DeckClientConfig {
3358 snapshot_poll_interval: Duration::from_secs(30),
3359 ..DeckClientConfig::default()
3360 },
3361 );
3362
3363 let watcher = {
3364 let client = DeckClient::new(
3365 deck.handle.clone(),
3366 deck.snapshot_reader.clone(),
3367 deck.identity().clone(),
3368 deck.config.clone(),
3369 );
3370 tokio::spawn(async move { client.watch(|s| s.freeze_remaining_ms.is_some()).await })
3371 };
3372
3373 tokio::time::sleep(Duration::from_millis(40)).await;
3374 let started = std::time::Instant::now();
3375 let p = deck
3376 .ice()
3377 .freeze_cluster(Duration::from_secs(15))
3378 .simulate()
3379 .await
3380 .expect("simulate");
3381 let sig = deck
3382 .identity()
3383 .sign_proposal(p.action(), p.issued_at_ms(), &p.blast_hash());
3384 p.commit(&[sig]).await.expect("commit");
3385
3386 let snap = tokio::time::timeout(Duration::from_secs(2), watcher)
3387 .await
3388 .expect("watch must resolve far inside the 30s ceiling")
3389 .expect("join");
3390 assert!(snap.freeze_remaining_ms.is_some());
3391 assert!(
3392 started.elapsed() < Duration::from_secs(5),
3393 "watch took {:?}, expected ≪ 30s ceiling — not event-driven",
3394 started.elapsed(),
3395 );
3396 let _ = runtime.shutdown().await;
3397 }
3398
3399 #[tokio::test]
3400 async fn watch_timeout_returns_watch_timeout_error_when_predicate_never_holds() {
3401 let dispatcher = Arc::new(LoggingDispatcher::new());
3402 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3403 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3404 DeckClientConfig {
3405 snapshot_poll_interval: Duration::from_millis(10),
3406 ..DeckClientConfig::default()
3407 },
3408 );
3409
3410 let err = deck
3411 .watch_timeout(
3412 |s| s.freeze_remaining_ms.is_some(),
3413 Duration::from_millis(80),
3414 )
3415 .await
3416 .expect_err("predicate never holds, should time out");
3417 assert_eq!(err.kind, "watch_timeout");
3418 let _ = runtime.shutdown().await;
3419 }
3420
3421 #[tokio::test]
3422 async fn audit_since_filter_drops_records_at_or_below_watermark() {
3423 let dispatcher = Arc::new(LoggingDispatcher::new());
3424 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3425 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3426
3427 deck.admin().cordon(42).await.unwrap();
3428 deck.admin().uncordon(42).await.unwrap();
3429 deck.admin().invalidate_placement(42).await.unwrap();
3430 tokio::time::sleep(Duration::from_millis(80)).await;
3431
3432 let all = deck.audit().collect();
3433 assert_eq!(all.len(), 3);
3434 let middle_seq = all[1].seq;
3438 let after_middle = deck.audit().since(middle_seq).collect();
3439 assert_eq!(after_middle.len(), 1, "since should keep only seq > middle");
3440 assert!(after_middle[0].seq > middle_seq);
3441 let _ = runtime.shutdown().await;
3442 }
3443
3444 #[tokio::test]
3445 async fn audit_stream_since_seeds_initial_watermark() {
3446 use futures::StreamExt;
3447 let dispatcher = Arc::new(LoggingDispatcher::new());
3448 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3449 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3450 DeckClientConfig {
3451 snapshot_poll_interval: Duration::from_millis(15),
3452 ..DeckClientConfig::default()
3453 },
3454 );
3455
3456 deck.admin().cordon(42).await.unwrap();
3458 deck.admin().uncordon(42).await.unwrap();
3459 deck.admin().invalidate_placement(42).await.unwrap();
3460 tokio::time::sleep(Duration::from_millis(80)).await;
3461
3462 let all = deck.audit().collect();
3463 assert_eq!(all.len(), 3);
3464 let middle_seq = all[1].seq;
3468 let mut stream = deck.audit().since(middle_seq).stream();
3469 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
3470 .await
3471 .expect("timed out")
3472 .expect("closed")
3473 .expect("ok");
3474 assert!(next.seq > middle_seq);
3475 let parked = tokio::time::timeout(Duration::from_millis(40), stream.next()).await;
3477 assert!(
3478 parked.is_err(),
3479 "stream should park after watermark catches up"
3480 );
3481 let _ = runtime.shutdown().await;
3482 }
3483
3484 #[tokio::test]
3485 async fn log_filter_since_seeds_stream_watermark() {
3486 use crate::adapter::net::behavior::meshos::{LogLine, MeshOsEvent};
3487 use futures::StreamExt;
3488 let dispatcher = Arc::new(LoggingDispatcher::new());
3489 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3490 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3491 DeckClientConfig {
3492 snapshot_poll_interval: Duration::from_millis(15),
3493 ..DeckClientConfig::default()
3494 },
3495 );
3496
3497 for i in 0..3 {
3499 runtime
3500 .handle()
3501 .publish(MeshOsEvent::LogLine(LogLine::info(
3502 None,
3503 format!("msg {i}"),
3504 )))
3505 .await
3506 .unwrap();
3507 }
3508 tokio::time::sleep(Duration::from_millis(80)).await;
3509
3510 let snap = runtime.snapshot();
3511 assert_eq!(snap.log_ring.len(), 3);
3512 let middle_seq = snap.log_ring[1].seq;
3513
3514 let mut stream = deck.subscribe_logs(LogFilter::new().since(middle_seq));
3517 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
3518 .await
3519 .expect("timed out")
3520 .expect("closed")
3521 .expect("ok");
3522 assert!(next.seq > middle_seq);
3523 assert_eq!(next.message, "msg 2");
3524 let _ = runtime.shutdown().await;
3525 }
3526
3527 #[tokio::test]
3528 async fn subscribe_logs_yields_published_log_lines_in_seq_order() {
3529 use crate::adapter::net::behavior::meshos::{LogLevel, LogLine, MeshOsEvent};
3530 use futures::StreamExt;
3531 let dispatcher = Arc::new(LoggingDispatcher::new());
3532 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3533 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3534 DeckClientConfig {
3535 snapshot_poll_interval: Duration::from_millis(15),
3536 ..DeckClientConfig::default()
3537 },
3538 );
3539
3540 let mut stream = deck.subscribe_logs(LogFilter::new());
3541 for (i, level) in [LogLevel::Info, LogLevel::Warn, LogLevel::Error]
3542 .into_iter()
3543 .enumerate()
3544 {
3545 runtime
3546 .handle()
3547 .publish(MeshOsEvent::LogLine(LogLine {
3548 level,
3549 daemon_id: Some(7),
3550 message: format!("msg {}", i),
3551 }))
3552 .await
3553 .unwrap();
3554 }
3555
3556 let r1 = tokio::time::timeout(Duration::from_secs(2), stream.next())
3557 .await
3558 .expect("r1 timed out")
3559 .expect("r1 closed")
3560 .expect("r1 ok");
3561 let r2 = tokio::time::timeout(Duration::from_secs(2), stream.next())
3562 .await
3563 .expect("r2 timed out")
3564 .expect("r2 closed")
3565 .expect("r2 ok");
3566 let r3 = tokio::time::timeout(Duration::from_secs(2), stream.next())
3567 .await
3568 .expect("r3 timed out")
3569 .expect("r3 closed")
3570 .expect("r3 ok");
3571 assert!(r1.seq < r2.seq);
3572 assert!(r2.seq < r3.seq);
3573 assert_eq!(r1.level, LogLevel::Info);
3574 assert_eq!(r3.level, LogLevel::Error);
3575 assert_eq!(r1.node_id, Some(42));
3578 let _ = runtime.shutdown().await;
3579 }
3580
3581 #[tokio::test]
3582 async fn subscribe_logs_min_level_filter_drops_below_threshold() {
3583 use crate::adapter::net::behavior::meshos::{LogLevel, LogLine, MeshOsEvent};
3584 use futures::StreamExt;
3585 let dispatcher = Arc::new(LoggingDispatcher::new());
3586 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3587 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3588 DeckClientConfig {
3589 snapshot_poll_interval: Duration::from_millis(15),
3590 ..DeckClientConfig::default()
3591 },
3592 );
3593
3594 let mut stream = deck.subscribe_logs(LogFilter::new().min_level(LogLevel::Warn));
3595 runtime
3596 .handle()
3597 .publish(MeshOsEvent::LogLine(LogLine::info(None, "info dropped")))
3598 .await
3599 .unwrap();
3600 runtime
3601 .handle()
3602 .publish(MeshOsEvent::LogLine(LogLine::warn(None, "warn kept")))
3603 .await
3604 .unwrap();
3605
3606 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
3607 .await
3608 .expect("next timed out")
3609 .expect("next closed")
3610 .expect("next ok");
3611 assert_eq!(next.level, LogLevel::Warn);
3612 assert_eq!(next.message, "warn kept");
3613 let _ = runtime.shutdown().await;
3614 }
3615
3616 #[tokio::test]
3617 async fn subscribe_logs_with_daemon_filter_keeps_only_matching_daemon() {
3618 use crate::adapter::net::behavior::meshos::{LogLine, MeshOsEvent};
3619 use futures::StreamExt;
3620 let dispatcher = Arc::new(LoggingDispatcher::new());
3621 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3622 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3623 DeckClientConfig {
3624 snapshot_poll_interval: Duration::from_millis(15),
3625 ..DeckClientConfig::default()
3626 },
3627 );
3628
3629 let mut stream = deck.subscribe_logs(LogFilter::new().with_daemon(7));
3630 runtime
3631 .handle()
3632 .publish(MeshOsEvent::LogLine(LogLine::info(
3633 Some(99),
3634 "other daemon",
3635 )))
3636 .await
3637 .unwrap();
3638 runtime
3639 .handle()
3640 .publish(MeshOsEvent::LogLine(LogLine::info(
3641 Some(7),
3642 "target daemon",
3643 )))
3644 .await
3645 .unwrap();
3646
3647 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
3648 .await
3649 .expect("next timed out")
3650 .expect("next closed")
3651 .expect("next ok");
3652 assert_eq!(next.daemon_id, Some(7));
3653 assert_eq!(next.message, "target daemon");
3654 let _ = runtime.shutdown().await;
3655 }
3656
3657 #[tokio::test]
3658 async fn audit_stream_emits_one_record_per_signed_commit_in_order() {
3659 use futures::StreamExt;
3660 let dispatcher = Arc::new(LoggingDispatcher::new());
3661 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3662 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3663 DeckClientConfig {
3664 snapshot_poll_interval: Duration::from_millis(15),
3665 ..DeckClientConfig::default()
3666 },
3667 );
3668
3669 let mut stream = deck.audit().stream();
3670 let first_attempt = tokio::time::timeout(Duration::from_millis(40), stream.next()).await;
3673 assert!(first_attempt.is_err(), "stream should park when no records");
3674
3675 deck.admin().cordon(42).await.unwrap();
3678 deck.admin().uncordon(42).await.unwrap();
3679 deck.admin().invalidate_placement(42).await.unwrap();
3680
3681 let r1 = tokio::time::timeout(Duration::from_secs(2), stream.next())
3682 .await
3683 .expect("r1 timed out")
3684 .expect("r1 closed")
3685 .expect("r1 ok");
3686 let r2 = tokio::time::timeout(Duration::from_secs(2), stream.next())
3687 .await
3688 .expect("r2 timed out")
3689 .expect("r2 closed")
3690 .expect("r2 ok");
3691 let r3 = tokio::time::timeout(Duration::from_secs(2), stream.next())
3692 .await
3693 .expect("r3 timed out")
3694 .expect("r3 closed")
3695 .expect("r3 ok");
3696
3697 assert!(r1.seq < r2.seq);
3700 assert!(r2.seq < r3.seq);
3701 let _ = runtime.shutdown().await;
3702 }
3703
3704 #[tokio::test]
3705 async fn audit_stream_dedups_already_seen_records_across_polls() {
3706 use futures::StreamExt;
3711 let dispatcher = Arc::new(LoggingDispatcher::new());
3712 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3713 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3714 DeckClientConfig {
3715 snapshot_poll_interval: Duration::from_millis(10),
3716 ..DeckClientConfig::default()
3717 },
3718 );
3719
3720 deck.admin().cordon(42).await.unwrap();
3721 let mut stream = deck.audit().stream();
3722 let first = tokio::time::timeout(Duration::from_secs(2), stream.next())
3723 .await
3724 .expect("first timed out")
3725 .expect("first closed")
3726 .expect("first ok");
3727
3728 let second_attempt = tokio::time::timeout(Duration::from_millis(50), stream.next()).await;
3731 assert!(
3732 second_attempt.is_err(),
3733 "stream should not re-emit seen record"
3734 );
3735
3736 deck.admin().uncordon(42).await.unwrap();
3738 let second = tokio::time::timeout(Duration::from_secs(2), stream.next())
3739 .await
3740 .expect("second timed out")
3741 .expect("second closed")
3742 .expect("second ok");
3743 assert!(second.seq > first.seq);
3744 let _ = runtime.shutdown().await;
3745 }
3746
3747 #[tokio::test(start_paused = true)]
3748 async fn audit_stream_rearms_waker_after_empty_tick() {
3749 use std::sync::atomic::{AtomicUsize, Ordering};
3759 use std::task::{Context, Poll, Wake, Waker};
3760
3761 struct CountingWaker(AtomicUsize);
3762 impl Wake for CountingWaker {
3763 fn wake(self: Arc<Self>) {
3764 self.0.fetch_add(1, Ordering::SeqCst);
3765 }
3766 fn wake_by_ref(self: &Arc<Self>) {
3767 self.0.fetch_add(1, Ordering::SeqCst);
3768 }
3769 }
3770
3771 let dispatcher = Arc::new(LoggingDispatcher::new());
3772 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3773 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3774 DeckClientConfig {
3775 snapshot_poll_interval: Duration::from_millis(10),
3776 ..DeckClientConfig::default()
3777 },
3778 );
3779
3780 let mut stream = deck.audit().stream();
3781 let counter = Arc::new(CountingWaker(AtomicUsize::new(0)));
3782 let waker = Waker::from(counter.clone());
3783 let mut cx = Context::from_waker(&waker);
3784
3785 let mut pinned = std::pin::pin!(&mut stream);
3790 let first = pinned.as_mut().poll_next(&mut cx);
3791 assert!(
3792 matches!(first, Poll::Pending),
3793 "empty ring should yield Pending, got {first:?}"
3794 );
3795 assert!(
3796 counter.0.load(Ordering::SeqCst) >= 1,
3797 "poll_next must re-register a waker after consuming an empty tick \
3798 (otherwise the stream parks forever)"
3799 );
3800
3801 let _ = runtime.shutdown().await;
3802 }
3803
3804 #[tokio::test]
3805 async fn audit_stream_applies_force_only_filter_in_tail_mode() {
3806 use futures::StreamExt;
3809 let dispatcher = Arc::new(LoggingDispatcher::new());
3810 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3811 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate()).with_config(
3812 DeckClientConfig {
3813 snapshot_poll_interval: Duration::from_millis(10),
3814 ..DeckClientConfig::default()
3815 },
3816 );
3817
3818 let mut stream = deck.audit().force_only().stream();
3819 deck.admin().cordon(42).await.unwrap();
3820 let thaw = deck
3821 .ice()
3822 .thaw_cluster()
3823 .simulate()
3824 .await
3825 .expect("simulate");
3826 let sig =
3827 deck.identity()
3828 .sign_proposal(thaw.action(), thaw.issued_at_ms(), &thaw.blast_hash());
3829 thaw.commit(&[sig]).await.unwrap();
3830
3831 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
3832 .await
3833 .expect("next timed out")
3834 .expect("next closed")
3835 .expect("next ok");
3836 assert!(next.event.is_ice());
3838 let _ = runtime.shutdown().await;
3839 }
3840
3841 #[tokio::test]
3842 async fn audit_query_returns_empty_when_no_ice_commits_observed() {
3843 let dispatcher = Arc::new(LoggingDispatcher::new());
3844 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3845 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3846 let results = deck.audit().recent(10).collect();
3847 assert!(results.is_empty());
3848 let _ = runtime.shutdown().await;
3849 }
3850
3851 #[tokio::test]
3852 async fn audit_query_returns_recent_entries_newest_first() {
3853 use crate::adapter::net::behavior::meshos::{IceActionProposal, MeshOsEvent};
3861 let dispatcher = Arc::new(LoggingDispatcher::new());
3862 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3863 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3864
3865 for ttl_secs in [10, 20, 30] {
3866 runtime
3867 .handle()
3868 .publish(MeshOsEvent::SignedIceCommit {
3869 proposal: IceActionProposal::FreezeCluster {
3870 ttl: Duration::from_secs(ttl_secs),
3871 },
3872 signatures: Vec::new(),
3873 issued_at_ms: super::super::meshos::now_ms_since_unix_epoch(),
3874 blast_hash: TEST_BLAST_HASH,
3875 })
3876 .await
3877 .unwrap();
3878 }
3879 tokio::time::sleep(Duration::from_millis(80)).await;
3880 let all = deck.audit().collect();
3881 assert_eq!(all.len(), 3, "ring should hold all three entries");
3882 assert!(matches!(
3885 all[0].event,
3886 AdminEvent::FreezeCluster { ttl } if ttl == Duration::from_secs(30)
3887 ));
3888 assert!(matches!(
3889 all[2].event,
3890 AdminEvent::FreezeCluster { ttl } if ttl == Duration::from_secs(10)
3891 ));
3892
3893 let recent_one = deck.audit().recent(1).collect();
3894 assert_eq!(recent_one.len(), 1);
3895 assert!(matches!(
3896 recent_one[0].event,
3897 AdminEvent::FreezeCluster { ttl } if ttl == Duration::from_secs(30)
3898 ));
3899 let _ = runtime.shutdown().await;
3900 }
3901
3902 #[tokio::test]
3903 async fn audit_query_filters_by_operator_id() {
3904 use crate::adapter::net::behavior::meshos::{IceActionProposal, MeshOsEvent};
3905 let dispatcher = Arc::new(LoggingDispatcher::new());
3906 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3907 let op_a = OperatorIdentity::generate();
3908 let op_b = OperatorIdentity::generate();
3909 let deck = DeckClient::from_runtime(&runtime, op_a.clone());
3910
3911 let proposal_a = IceActionProposal::FreezeCluster {
3913 ttl: Duration::from_secs(10),
3914 };
3915 let ts_a = super::super::meshos::now_ms_since_unix_epoch();
3916 let sig_a = OperatorSignature::sign(op_a.keypair(), &proposal_a, ts_a, &TEST_BLAST_HASH);
3917 runtime
3918 .handle()
3919 .publish(MeshOsEvent::SignedIceCommit {
3920 proposal: proposal_a,
3921 signatures: vec![sig_a],
3922 issued_at_ms: ts_a,
3923 blast_hash: TEST_BLAST_HASH,
3924 })
3925 .await
3926 .unwrap();
3927 let proposal_b = IceActionProposal::ThawCluster;
3929 let ts_b = super::super::meshos::now_ms_since_unix_epoch();
3930 let sig_b = OperatorSignature::sign(op_b.keypair(), &proposal_b, ts_b, &TEST_BLAST_HASH);
3931 runtime
3932 .handle()
3933 .publish(MeshOsEvent::SignedIceCommit {
3934 proposal: proposal_b,
3935 signatures: vec![sig_b],
3936 issued_at_ms: ts_b,
3937 blast_hash: TEST_BLAST_HASH,
3938 })
3939 .await
3940 .unwrap();
3941 tokio::time::sleep(Duration::from_millis(80)).await;
3942
3943 let filtered = deck.audit().by_operator(op_a.operator_id()).collect();
3944 assert_eq!(filtered.len(), 1);
3945 assert!(matches!(
3946 filtered[0].event,
3947 AdminEvent::FreezeCluster { .. }
3948 ));
3949 let _ = runtime.shutdown().await;
3950 }
3951
3952 #[tokio::test]
3953 async fn audit_query_force_only_drops_ordinary_admin_keeps_ice() {
3954 let dispatcher = Arc::new(LoggingDispatcher::new());
3957 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
3958 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
3959
3960 deck.admin().cordon(42).await.expect("cordon");
3961 let thaw = deck
3962 .ice()
3963 .thaw_cluster()
3964 .simulate()
3965 .await
3966 .expect("simulate");
3967 let sig =
3968 deck.identity()
3969 .sign_proposal(thaw.action(), thaw.issued_at_ms(), &thaw.blast_hash());
3970 thaw.commit(&[sig]).await.expect("thaw");
3971 tokio::time::sleep(Duration::from_millis(80)).await;
3972
3973 let baseline = deck.audit().collect();
3974 assert_eq!(
3975 baseline.len(),
3976 2,
3977 "ring should hold both ordinary and ICE commits"
3978 );
3979 let force_only = deck.audit().force_only().collect();
3980 assert_eq!(force_only.len(), 1, "force_only should drop Cordon");
3981 assert!(force_only[0].event.is_ice());
3982 let _ = runtime.shutdown().await;
3983 }
3984
3985 #[tokio::test]
3986 async fn admin_commit_routes_through_signed_path_when_registry_installed() {
3987 use std::sync::Arc as SArc;
3992 let dispatcher = Arc::new(LoggingDispatcher::new());
3993 let identity = OperatorIdentity::generate();
3994 let mut registry = OperatorRegistry::new();
3995 registry.register(identity.keypair());
3996 let verifier = SArc::new(crate::adapter::net::behavior::meshos::AdminVerifier::new(
3997 SArc::new(registry.clone()),
3998 1,
3999 ));
4000 let runtime = MeshOsRuntime::start_with_all(
4001 fast_config(),
4002 dispatcher,
4003 Default::default(),
4004 Default::default(),
4005 SArc::new(crate::adapter::net::compute::DaemonRegistry::new()),
4006 None,
4007 Some(verifier),
4008 );
4009 let deck =
4010 DeckClient::from_runtime(&runtime, identity.clone()).with_operator_registry(registry);
4011
4012 let commit = deck.admin().cordon(42).await.expect("commit");
4013 assert_eq!(commit.event_kind(), "cordon");
4014
4015 tokio::time::sleep(Duration::from_millis(80)).await;
4018 let entries = deck.audit().collect();
4019 assert_eq!(entries.len(), 1);
4020 assert!(matches!(
4021 entries[0].outcome,
4022 crate::adapter::net::behavior::meshos::VerificationOutcome::Accepted
4023 ));
4024 assert_eq!(entries[0].operator_ids, vec![identity.operator_id()]);
4025 let _ = runtime.shutdown().await;
4026 }
4027
4028 #[tokio::test]
4029 async fn admin_commit_falls_back_to_unsigned_when_no_registry_installed() {
4030 let dispatcher = Arc::new(LoggingDispatcher::new());
4034 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
4035 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
4036
4037 deck.admin().cordon(42).await.expect("commit");
4038 tokio::time::sleep(Duration::from_millis(80)).await;
4039
4040 let entries = deck.audit().collect();
4041 assert_eq!(entries.len(), 1);
4042 assert!(matches!(
4043 entries[0].outcome,
4044 crate::adapter::net::behavior::meshos::VerificationOutcome::Unverified
4045 ));
4046 assert!(entries[0].operator_ids.is_empty());
4047 let _ = runtime.shutdown().await;
4048 }
4049
4050 #[tokio::test]
4051 async fn audit_ring_records_unsigned_admin_with_unverified_outcome() {
4052 let dispatcher = Arc::new(LoggingDispatcher::new());
4056 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
4057 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
4058
4059 deck.admin().cordon(42).await.expect("cordon");
4060 deck.admin()
4061 .drop_replicas(42, vec![1, 2])
4062 .await
4063 .expect("drop_replicas");
4064 tokio::time::sleep(Duration::from_millis(80)).await;
4065
4066 let entries = deck.audit().collect();
4067 assert_eq!(entries.len(), 2);
4068 for entry in &entries {
4069 assert!(matches!(
4070 entry.outcome,
4071 crate::adapter::net::behavior::meshos::VerificationOutcome::Unverified
4072 ));
4073 assert!(entry.operator_ids.is_empty());
4074 }
4075 let _ = runtime.shutdown().await;
4076 }
4077
4078 #[tokio::test]
4079 async fn audit_query_between_filters_outside_window() {
4080 use crate::adapter::net::behavior::meshos::{IceActionProposal, MeshOsEvent};
4081 let dispatcher = Arc::new(LoggingDispatcher::new());
4082 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
4083 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
4084
4085 runtime
4086 .handle()
4087 .publish(MeshOsEvent::SignedIceCommit {
4088 proposal: IceActionProposal::ThawCluster,
4089 signatures: Vec::new(),
4090 issued_at_ms: super::super::meshos::now_ms_since_unix_epoch(),
4091 blast_hash: TEST_BLAST_HASH,
4092 })
4093 .await
4094 .unwrap();
4095 tokio::time::sleep(Duration::from_millis(80)).await;
4096
4097 let past_only = deck.audit().between(0, 1).collect();
4100 assert!(past_only.is_empty());
4101
4102 let now_ms = std::time::SystemTime::now()
4104 .duration_since(std::time::UNIX_EPOCH)
4105 .unwrap()
4106 .as_millis() as u64;
4107 let around_now = deck
4108 .audit()
4109 .between(now_ms - 10_000, now_ms + 10_000)
4110 .collect();
4111 assert_eq!(around_now.len(), 1);
4112 let _ = runtime.shutdown().await;
4113 }
4114
4115 #[tokio::test]
4116 async fn ice_force_restart_daemon_proposal_round_trips() {
4117 let dispatcher = Arc::new(LoggingDispatcher::new());
4118 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
4119 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
4120 let daemon = super::super::meshos::DaemonRef {
4121 id: 7,
4122 name: "telemetry".into(),
4123 };
4124 let proposal = deck.ice().force_restart_daemon(daemon.clone());
4125 let simulated = proposal.simulate().await.expect("simulate");
4126 assert_eq!(simulated.blast_radius().affected_daemons, vec![daemon]);
4127 let sig = deck.identity().sign_proposal(
4128 simulated.action(),
4129 simulated.issued_at_ms(),
4130 &simulated.blast_hash(),
4131 );
4132 let commit = simulated.commit(&[sig]).await.expect("commit");
4133 assert_eq!(commit.event_kind(), "force_restart_daemon");
4134 let _ = runtime.shutdown().await;
4135 }
4136
4137 #[tokio::test]
4138 async fn ice_kill_migration_proposal_round_trips_and_audits() {
4139 let dispatcher = Arc::new(LoggingDispatcher::new());
4140 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
4141 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
4142 let proposal = deck.ice().kill_migration(123);
4143 let simulated = proposal.simulate().await.expect("simulate");
4144 let sig = deck.identity().sign_proposal(
4145 simulated.action(),
4146 simulated.issued_at_ms(),
4147 &simulated.blast_hash(),
4148 );
4149 let commit = simulated.commit(&[sig]).await.expect("commit");
4150 assert_eq!(commit.event_kind(), "kill_migration");
4151
4152 tokio::time::sleep(Duration::from_millis(60)).await;
4155 let entries = deck.audit().force_only().collect();
4156 assert!(entries
4157 .iter()
4158 .any(|r| matches!(r.event, AdminEvent::KillMigration { migration: 123 })));
4159 let _ = runtime.shutdown().await;
4160 }
4161
4162 #[tokio::test]
4163 async fn ice_force_cutover_proposal_round_trips() {
4164 let dispatcher = Arc::new(LoggingDispatcher::new());
4165 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
4166 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
4167 let proposal = deck.ice().force_cutover(100, 42);
4168 let simulated = proposal.simulate().await.expect("simulate");
4169 assert_eq!(simulated.blast_radius().affected_replicas, vec![100]);
4170 assert_eq!(simulated.blast_radius().affected_nodes, vec![42]);
4171 let sig = deck.identity().sign_proposal(
4172 simulated.action(),
4173 simulated.issued_at_ms(),
4174 &simulated.blast_hash(),
4175 );
4176 let commit = simulated.commit(&[sig]).await.expect("commit");
4177 assert_eq!(commit.event_kind(), "force_cutover");
4178 let _ = runtime.shutdown().await;
4179 }
4180
4181 #[tokio::test]
4182 async fn ice_force_evict_replica_proposal_round_trips() {
4183 let dispatcher = Arc::new(LoggingDispatcher::new());
4184 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
4185 let deck = DeckClient::from_runtime(&runtime, OperatorIdentity::generate());
4186 let proposal = deck.ice().force_evict_replica(100, 7);
4187 let simulated = proposal.simulate().await.expect("simulate");
4188 assert_eq!(simulated.blast_radius().affected_replicas, vec![100]);
4189 assert_eq!(simulated.blast_radius().affected_nodes, vec![7]);
4190 let sig = deck.identity().sign_proposal(
4191 simulated.action(),
4192 simulated.issued_at_ms(),
4193 &simulated.blast_hash(),
4194 );
4195 let commit = simulated.commit(&[sig]).await.expect("commit");
4196 assert_eq!(commit.event_kind(), "force_evict_replica");
4197 let _ = runtime.shutdown().await;
4198 }
4199
4200 #[tokio::test]
4201 async fn ice_commit_with_registry_accepts_a_valid_multi_op_bundle() {
4202 let dispatcher = Arc::new(LoggingDispatcher::new());
4203 let runtime = MeshOsRuntime::start(fast_config(), dispatcher);
4204 let op_a = OperatorIdentity::generate();
4205 let op_b = OperatorIdentity::generate();
4206 let mut registry = OperatorRegistry::new();
4207 registry.register(op_a.keypair());
4208 registry.register(op_b.keypair());
4209 let deck = DeckClient::new(
4210 runtime.handle_clone(),
4211 runtime.snapshot_reader().clone(),
4212 op_a.clone(),
4213 DeckClientConfig {
4214 snapshot_poll_interval: Duration::from_millis(100),
4215 ice_signature_threshold: 2,
4216 },
4217 )
4218 .with_operator_registry(registry);
4219
4220 let proposal = deck.ice().freeze_cluster(Duration::from_secs(15));
4221 let simulated = proposal.simulate().await.expect("simulate");
4222 let sig_a = op_a.sign_proposal(
4223 simulated.action(),
4224 simulated.issued_at_ms(),
4225 &simulated.blast_hash(),
4226 );
4227 let sig_b = op_b.sign_proposal(
4228 simulated.action(),
4229 simulated.issued_at_ms(),
4230 &simulated.blast_hash(),
4231 );
4232 let commit = simulated
4233 .commit(&[sig_a, sig_b])
4234 .await
4235 .expect("valid multi-op bundle should commit");
4236 assert_eq!(commit.event_kind(), "freeze_cluster");
4237 let _ = runtime.shutdown().await;
4238 }
4239}