1use crate::cli::commands::start::MeasurementExecutionArgs;
2use crate::cli::feed::{FEED_CHANNEL_SIZE, FeedOrigins, FeedStream, read_stdin_feed};
3use crate::cli::writer::parquet_writer::write_results_parquet;
4use crate::cli::writer::{MetadataArgs, WriteConfig, write_results_csv};
5use crate::custom_module::manycastr::ProtocolType::ChaosDns;
6use crate::custom_module::manycastr::controller_client::ControllerClient;
7use crate::custom_module::manycastr::{
8 CliMessage, MeasurementType, ReplyBatch, ScheduleMeasurement, cli_message,
9};
10use crate::custom_module::{Separated, has_anycast_origin};
11use crate::tls::TlsOptions;
12use crate::{ALL_WORKERS, SINGLE_ORIGIN};
13use chrono::Local;
14use indicatif::{ProgressBar, ProgressStyle};
15use log::{error, info, warn};
16use std::collections::HashSet;
17use std::error::Error;
18use std::fs::File;
19use std::path::Path;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23use tokio::sync::mpsc::{channel, unbounded_channel};
24use tonic::transport::Channel;
25use tonic::{Request, Streaming};
26
27pub struct CliClient {
29 pub(crate) grpc_client: ControllerClient<Channel>,
30}
31
32impl CliClient {
33 pub(crate) async fn do_measurement_to_server(
39 &mut self,
40 m_def: ScheduleMeasurement,
41 args: MeasurementExecutionArgs<'_>,
42 ) -> Result<(), Box<dyn Error>> {
43 let probing_rate = m_def.probing_rate;
44 let worker_interval = m_def.worker_interval;
45
46 let number_of_probers = {
48 let worker_ids: HashSet<_> = m_def
49 .configurations
50 .iter()
51 .map(|conf| conf.worker_id)
52 .collect();
53
54 if worker_ids.contains(&ALL_WORKERS) {
55 args.worker_map.len()
56 } else {
57 worker_ids.len()
58 }
59 };
60
61 let is_divided = match m_def.m_type() {
63 MeasurementType::Catchment | MeasurementType::Tracemap => true,
64 MeasurementType::AnycastLatency => has_anycast_origin(&m_def.configurations),
65 _ => false,
66 };
67
68 let m_time = if is_divided {
69 ((args.hitlist_length as f32 / (probing_rate as f32 * number_of_probers as f32)) + 5.0)
70 / 60.0
71 } else {
72 ((number_of_probers.saturating_sub(1) as f32 * worker_interval as f32) + (args.hitlist_length as f32 / probing_rate as f32) + 5.0) / 60.0 };
77
78 info!("[CLI] Performing {} measurement", m_def.m_type());
79 if m_def.is_responsive {
80 info!(
82 "[CLI] This measurement will take at most an estimated {m_time:.2} minutes (less, depending on target responsiveness)"
83 );
84 } else {
85 info!("[CLI] This measurement will take an estimated {m_time:.2} minutes");
86 }
87
88 let response = self
89 .grpc_client
90 .do_measurement(Request::new(m_def.clone()))
91 .await;
92 if let Err(e) = response {
93 error!(
94 "[CLI] Orchestrator did not perform the measurement: '{}'",
95 e.message()
96 );
97 return Err(Box::new(e));
98 }
99 let stream = response
101 .expect("Unable to obtain the orchestrator stream")
102 .into_inner();
103
104 stream_results_to_file(stream, &m_def, args, Some(m_time)).await
105 }
106
107 pub(crate) async fn do_live_measurement_to_server(
116 &mut self,
117 m_def: ScheduleMeasurement,
118 args: MeasurementExecutionArgs<'_>,
119 ) -> Result<(), Box<dyn Error>> {
120 info!(
121 "[CLI] Performing live {} measurement; reading NDJSON targets from stdin (e.g., {{\"dst\":\"1.1.1.1\"}})",
122 m_def.m_type()
123 );
124
125 let (feed_tx, mut feed_rx) = channel::<CliMessage>(FEED_CHANNEL_SIZE);
127 let (grpc_tx, grpc_rx) = channel::<CliMessage>(16);
128
129 grpc_tx
131 .send(CliMessage {
132 message: Some(cli_message::Message::Start(m_def.clone())),
133 })
134 .await?;
135
136 let worker_map = args.worker_map.clone();
138 let origins = FeedOrigins::new(&m_def.configurations);
140 let is_trace = m_def.m_type() == MeasurementType::FeedTrace;
141 let is_sessions = args.is_sessions;
142 std::thread::spawn(move || {
143 read_stdin_feed(feed_tx, worker_map, origins, is_trace, is_sessions)
144 });
145
146 tokio::spawn(async move {
148 loop {
149 tokio::select! {
150 _ = tokio::signal::ctrl_c() => {
151 info!("[CLI] Ctrl+C received, exiting...");
152 std::process::exit(130);
153 }
154 msg = feed_rx.recv() => match msg {
155 Some(msg) => {
156 if grpc_tx.send(msg).await.is_err() {
157 break; }
159 }
160 None => {
161 info!("[CLI] Live feed reached EOF, awaiting last results");
162 break;
163 }
164 }
165 }
166 }
167 });
168
169 let response = self
171 .grpc_client
172 .live_measurement(Request::new(FeedStream { inner: grpc_rx }))
173 .await;
174 if let Err(e) = response {
175 error!(
176 "[CLI] Orchestrator did not perform the live measurement: '{}'",
177 e.message()
178 );
179 return Err(Box::new(e));
180 }
181
182 let stream = response
184 .expect("Unable to obtain the orchestrator stream")
185 .into_inner();
186
187 stream_results_to_file(stream, &m_def, args, None).await
188 }
189}
190
191async fn stream_results_to_file(
196 mut stream: Streaming<ReplyBatch>,
197 m_def: &ScheduleMeasurement,
198 args: MeasurementExecutionArgs<'_>,
199 m_time: Option<f32>,
200) -> Result<(), Box<dyn Error>> {
201 let start = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
203 let timestamp_start_str = Local::now().format("%Y%m%d-%H%M%S").to_string();
204
205 let is_done = Arc::new(AtomicBool::new(false));
206 if let Some(m_time) = m_time {
208 let total_steps = (m_time * 60.0) as u64; let pb = ProgressBar::new(total_steps);
210 pb.set_style(
211 ProgressStyle::with_template(
212 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
213 )?
214 .progress_chars("#>-"),
215 );
216 let is_done_clone = is_done.clone();
217 let is_cli = args.is_cli;
218
219 tokio::spawn(async move {
221 if !is_cli {
223 for _ in 0..total_steps {
224 if is_done_clone.load(Ordering::Relaxed) {
225 break;
226 }
227 pb.inc(1); tokio::time::sleep(Duration::from_secs(1)).await; }
230 }
231 });
232 }
233
234 let mut graceful = false; let (tx_r, rx_r) = unbounded_channel();
237
238 let proto_str = {
240 let mut it = m_def
241 .configurations
242 .iter()
243 .map(|c| c.origin.expect("none origin").p_type());
244 let first = it.next().unwrap();
245 if it.all(|p| p == first) {
246 first.as_str()
248 } else {
249 "multi"
251 }
252 };
253
254 let mut is_parquet = args.is_parquet;
256
257 let extension = if is_parquet { ".parquet" } else { ".csv.gz" };
258
259 let type_token = match m_def.m_type() {
261 MeasurementType::AnycastLatency => {
262 if has_anycast_origin(&m_def.configurations) {
263 "anycast-latency"
264 } else {
265 "unicast-latency"
266 }
267 }
268 other => other.as_str(),
269 };
270
271 let version_token = args.versions.file_token();
272
273 let path = Path::new(&args.out_path);
274 let file_path = if args.out_path.ends_with('/') || path.is_dir() {
275 path.join(format!(
277 "{type_token}-{proto_str}-{version_token}-{timestamp_start_str}{extension}"
278 ))
279 } else {
280 if args.out_path.ends_with(".parquet") {
281 is_parquet = true;
282 }
283 path.to_path_buf()
284 };
285
286 info!("[CLI] Writing results to {}", file_path.display());
288 let file = File::create(file_path).expect("Unable to create file");
289
290 let metadata_args = MetadataArgs {
291 hitlist: args.hitlist_path,
292 hitlist_length: args.hitlist_length,
293 is_shuffle: args.is_shuffle,
294 probing_rate: m_def.probing_rate,
295 interval: m_def.worker_interval,
296 all_workers: &args.worker_map,
297 configurations: &m_def.configurations,
298 is_responsive: m_def.is_responsive,
299 m_type: m_def.m_type(),
300 start_time: start,
301 record: m_def.record.as_deref(),
302 url: m_def.url.as_deref(),
303 probe_interval: m_def.probe_interval,
304 number_of_probes: m_def.number_of_probes,
305 };
306
307 let is_multi_origin = m_def.configurations.iter().any(|conf| {
309 conf.origin
310 .as_ref()
311 .is_some_and(|origin| origin.origin_id != SINGLE_ORIGIN)
312 });
313
314 let is_chaos = m_def.configurations.iter().any(|conf| {
316 conf.origin
317 .as_ref()
318 .is_some_and(|origin| origin.p_type() == ChaosDns)
319 });
320
321 let config = WriteConfig {
322 print_to_cli: args.is_cli,
323 output_file: file,
324 metadata_args,
325 m_type: m_def.m_type(),
326 is_multi_origin,
327 worker_map: args.worker_map.clone(),
328 is_chaos,
329 is_sessions: args.is_sessions,
330 };
331
332 if is_parquet {
334 write_results_parquet(rx_r, config);
335 } else {
336 write_results_csv(rx_r, config);
337 }
338
339 let mut replies_count = 0;
340 'mloop: while let Some(task_result) = match stream.message().await {
341 Ok(Some(result)) => Some(result),
342 Ok(None) => {
343 error!("[CLI] Stream closed by orchestrator");
344 break 'mloop;
345 } Err(e) => {
347 error!("[CLI] Error receiving message: {e}");
348 break 'mloop;
349 }
350 } {
351 if task_result == ReplyBatch::default() {
353 tx_r.send(task_result)?; graceful = true;
355 break;
356 }
357
358 replies_count += task_result.results.len();
359 tx_r.send(task_result)?;
361 }
362
363 is_done.store(true, Ordering::Relaxed); let end = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
366 let length = (end - start) as f32 / 60.0; info!(
368 "[CLI] Waited {length:.2} minutes for results. Captured {} replies",
369 replies_count.with_separator()
370 );
371
372 if !graceful {
374 tx_r.send(ReplyBatch::default())?; warn!("[CLI] Measurement ended prematurely!");
376 }
377
378 tx_r.closed().await; Ok(())
381}
382
383impl CliClient {
384 pub(crate) async fn connect(
396 address: &str,
397 tls: &TlsOptions<'_>,
398 ) -> Result<ControllerClient<Channel>, Box<dyn Error>> {
399 let scheme = if tls.is_enabled() { "https" } else { "http" };
400 let mut endpoint = Channel::from_shared(format!("{scheme}://{address}"))?;
401
402 if tls.is_enabled() {
403 endpoint = endpoint.tls_config(tls.client_config(address)?)?;
404 }
405
406 let channel = endpoint.connect().await.map_err(|e| format!("{e:?}"))?;
407 let client = ControllerClient::new(channel);
409
410 Ok(client)
411 }
412}