serai_full_stack_tests/
lib.rs

1/*
2#![allow(clippy::needless_pass_by_ref_mut)] // False positives
3
4use std::{
5  sync::{OnceLock, Mutex},
6  time::Duration,
7  fs,
8};
9
10use zeroize::Zeroizing;
11
12use ciphersuite::{group::ff::PrimeField, Ciphersuite, Ristretto};
13
14use serai_client::primitives::NetworkId;
15
16use messages::{CoordinatorMessage, ProcessorMessage};
17use serai_message_queue::{Service, Metadata, client::MessageQueue};
18
19use serai_client::Serai;
20
21use dockertest::{
22  PullPolicy, Image, LogAction, LogPolicy, LogSource, LogOptions, StartPolicy, Composition,
23  DockerOperations,
24};
25
26#[cfg(test)]
27mod tests;
28
29static UNIQUE_ID: OnceLock<Mutex<u16>> = OnceLock::new();
30
31pub fn coordinator_instance(
32  name: &str,
33  message_queue_key: <Ristretto as Ciphersuite>::F,
34) -> Composition {
35  serai_docker_tests::build("coordinator".to_string());
36
37  Composition::with_image(
38    Image::with_repository("serai-dev-coordinator").pull_policy(PullPolicy::Never),
39  )
40  .with_env(
41    [
42      ("MESSAGE_QUEUE_KEY".to_string(), hex::encode(message_queue_key.to_repr())),
43      ("DB_PATH".to_string(), "./coordinator-db".to_string()),
44      ("SERAI_KEY".to_string(), {
45        use serai_client::primitives::insecure_pair_from_name;
46        hex::encode(&insecure_pair_from_name(name).as_ref().secret.to_bytes()[.. 32])
47      }),
48      (
49        "RUST_LOG".to_string(),
50        "serai_coordinator=trace,".to_string() + "tributary_chain=trace," + "tendermint=trace",
51      ),
52    ]
53    .into(),
54  )
55}
56
57pub fn serai_composition(name: &str) -> Composition {
58  serai_docker_tests::build("serai".to_string());
59
60  let mut composition = Composition::with_image(
61    Image::with_repository("serai-dev-serai").pull_policy(PullPolicy::Never),
62  )
63  .with_cmd(vec![
64    "serai-node".to_string(),
65    "--unsafe-rpc-external".to_string(),
66    "--rpc-cors".to_string(),
67    "all".to_string(),
68    "--chain".to_string(),
69    "local".to_string(),
70    format!("--{}", name.to_lowercase()),
71  ]);
72  composition.publish_all_ports();
73  composition
74}
75
76pub type Handles = (String, String, String);
77pub fn coordinator_stack(name: &str) -> (Handles, <Ristretto as Ciphersuite>::F, Vec<Composition>) {
78  let serai_composition = serai_composition(name);
79
80  let (coord_key, message_queue_keys, message_queue_composition) =
81    serai_message_queue_tests::instance();
82
83  let coordinator_composition = coordinator_instance(name, coord_key);
84
85  // Give every item in this stack a unique ID
86  // Uses a Mutex as we can't generate a 8-byte random ID without hitting hostname length limits
87  let (first, unique_id) = {
88    let unique_id_mutex = UNIQUE_ID.get_or_init(|| Mutex::new(0));
89    let mut unique_id_lock = unique_id_mutex.lock().unwrap();
90    let first = *unique_id_lock == 0;
91    let unique_id = hex::encode(unique_id_lock.to_be_bytes());
92    *unique_id_lock += 1;
93    (first, unique_id)
94  };
95
96  let logs_path = [std::env::current_dir().unwrap().to_str().unwrap(), ".test-logs", "coordinator"]
97    .iter()
98    .collect::<std::path::PathBuf>();
99  if first {
100    let _ = fs::remove_dir_all(&logs_path);
101    fs::create_dir_all(&logs_path).expect("couldn't create logs directory");
102    assert!(
103      fs::read_dir(&logs_path).expect("couldn't read the logs folder").next().is_none(),
104      "logs folder wasn't empty, despite removing it at the start of the run",
105    );
106  }
107  let logs_path = logs_path.to_str().unwrap().to_string();
108
109  let mut compositions = vec![];
110  let mut handles = vec![];
111  for composition in [serai_composition, message_queue_composition, coordinator_composition] {
112    let name = format!("{}-{}", composition.handle(), &unique_id);
113
114    compositions.push(
115      composition
116        .with_start_policy(StartPolicy::Strict)
117        .with_container_name(name.clone())
118        .with_log_options(Some(LogOptions {
119          action: LogAction::ForwardToFile { path: logs_path.clone() },
120          policy: LogPolicy::Always,
121          source: LogSource::Both,
122        })),
123    );
124
125    handles.push(compositions.last().unwrap().handle());
126  }
127
128  let coordinator_composition = compositions.last_mut().unwrap();
129  coordinator_composition.inject_container_name(handles.remove(0), "SERAI_HOSTNAME");
130  coordinator_composition.inject_container_name(handles.remove(0), "MESSAGE_QUEUE_RPC");
131
132  (
133    (compositions[0].handle(), compositions[1].handle(), compositions[2].handle()),
134    message_queue_keys[&NetworkId::Bitcoin],
135    compositions,
136  )
137}
138
139pub struct Processor {
140  network: NetworkId,
141
142  serai_rpc: String,
143  #[allow(unused)]
144  message_queue_handle: String,
145  #[allow(unused)]
146  coordinator_handle: String,
147
148  next_send_id: u64,
149  next_recv_id: u64,
150  queue: MessageQueue,
151}
152
153impl Processor {
154  pub async fn new(
155    network: NetworkId,
156    ops: &DockerOperations,
157    handles: (String, String, String),
158    processor_key: <Ristretto as Ciphersuite>::F,
159  ) -> Processor {
160    let message_queue_rpc = ops.handle(&handles.1).host_port(2287).unwrap();
161    let message_queue_rpc = format!("{}:{}", message_queue_rpc.0, message_queue_rpc.1);
162
163    // Sleep until the Substrate RPC starts
164    let serai_rpc = ops.handle(&handles.0).host_port(9944).unwrap();
165    let serai_rpc = format!("ws://{}:{}", serai_rpc.0, serai_rpc.1);
166    // Bound execution to 60 seconds
167    for _ in 0 .. 60 {
168      tokio::time::sleep(Duration::from_secs(1)).await;
169      let Ok(client) = Serai::new(&serai_rpc).await else { continue };
170      if client.get_latest_block_hash().await.is_err() {
171        continue;
172      }
173      break;
174    }
175
176    // The Serai RPC may or may not be started
177    // Assume it is and continue, so if it's a few seconds late, it's still within tolerance
178
179    Processor {
180      network,
181
182      serai_rpc,
183      message_queue_handle: handles.1,
184      coordinator_handle: handles.2,
185
186      next_send_id: 0,
187      next_recv_id: 0,
188      queue: MessageQueue::new(
189        Service::Processor(network),
190        message_queue_rpc,
191        Zeroizing::new(processor_key),
192      ),
193    }
194  }
195
196  pub async fn serai(&self) -> Serai {
197    Serai::new(&self.serai_rpc).await.unwrap()
198  }
199
200  /// Send a message to a processor as its coordinator.
201  pub async fn send_message(&mut self, msg: impl Into<ProcessorMessage>) {
202    let msg: ProcessorMessage = msg.into();
203    self
204      .queue
205      .queue(
206        Metadata {
207          from: Service::Processor(self.network),
208          to: Service::Coordinator,
209          intent: msg.intent(),
210        },
211        serde_json::to_string(&msg).unwrap().into_bytes(),
212      )
213      .await;
214    self.next_send_id += 1;
215  }
216
217  /// Receive a message from a processor as its coordinator.
218  pub async fn recv_message(&mut self) -> CoordinatorMessage {
219    let msg = tokio::time::timeout(Duration::from_secs(10), self.queue.next(self.next_recv_id))
220      .await
221      .unwrap();
222    assert_eq!(msg.from, Service::Coordinator);
223    assert_eq!(msg.id, self.next_recv_id);
224    self.queue.ack(self.next_recv_id).await;
225    self.next_recv_id += 1;
226    serde_json::from_slice(&msg.msg).unwrap()
227  }
228}
229*/