Skip to main content

manycastr/cli/
client.rs

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
27/// A CLI client that creates a connection with the 'orchestrator' and sends the desired commands based on the command-line input.
28pub struct CliClient {
29    pub(crate) grpc_client: ControllerClient<Channel>,
30}
31
32impl CliClient {
33    /// Perform a measurement at the orchestrator, await measurement results, and write them to a file.
34    ///
35    /// # Arguments
36    /// * `m_def` - measurement definition  for the orchestrator created from the command-line arguments
37    /// * `args` - contains additional arguments for the measurement execution
38    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        // Get number of probers
47        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        // Latency anycast measurements divide the hitlist among workers
62        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) // Last worker starts probing
73            + (args.hitlist_length as f32 / probing_rate as f32) // Time to probe all addresses
74            + 5.0) // Time to wait for last replies
75            / 60.0 // Convert to minutes
76        };
77
78        info!("[CLI] Performing {} measurement", m_def.m_type());
79        if m_def.is_responsive {
80            // Non-deterministic (depending on responsiveness)
81            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        // Obtain the Stream from the orchestrator and read from it
100        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    /// Perform a live (feed-based) measurement at the orchestrator.
108    ///
109    /// Opens a bidirectional stream: the measurement definition is sent first, then
110    /// NDJSON targets read from stdin are forwarded as they arrive.
111    ///
112    /// # Arguments
113    /// * `m_def` - measurement definition for the orchestrator (empty hitlist)
114    /// * `args` - contains additional arguments for the measurement execution
115    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        // Bounded channels: when the orchestrator (or its rate limit) cannot keep up, block stdin
126        let (feed_tx, mut feed_rx) = channel::<CliMessage>(FEED_CHANNEL_SIZE);
127        let (grpc_tx, grpc_rx) = channel::<CliMessage>(16);
128
129        // The first message on the stream must be the measurement definition
130        grpc_tx
131            .send(CliMessage {
132                message: Some(cli_message::Message::Start(m_def.clone())),
133            })
134            .await?;
135
136        // Read NDJSON targets from stdin on a blocking thread
137        let worker_map = args.worker_map.clone();
138        // The configured origins, and the default origin per IP version
139        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        // Forward stdin targets to the gRPC stream until EOF or Ctrl+C.
147        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; // Orchestrator closed the stream
158                            }
159                        }
160                        None => {
161                            info!("[CLI] Live feed reached EOF, awaiting last results");
162                            break;
163                        }
164                    }
165                }
166            }
167        });
168
169        // Handle measurement replies
170        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        // Get stream of measurement replies and write to file
183        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
191/// Consume the orchestrator's result stream and write the replies to file.
192///
193/// Shared by hitlist-based and live measurements. A progress bar is shown only when
194/// an estimated measurement duration is provided (live measurements are open-ended).
195async 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    // Get start time of measurement
202    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    // Progress bar (only when the measurement duration can be estimated)
207    if let Some(m_time) = m_time {
208        let total_steps = (m_time * 60.0) as u64; // measurement_length in seconds
209        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        // Spawn a separate async task to update the progress bar
220        tokio::spawn(async move {
221            // If we are streaming to the CLI, we cannot use a progress bar
222            if !is_cli {
223                for _ in 0..total_steps {
224                    if is_done_clone.load(Ordering::Relaxed) {
225                        break;
226                    }
227                    pb.inc(1); // Increment the progress bar by one step
228                    tokio::time::sleep(Duration::from_secs(1)).await; // Simulate time taken for each step
229                }
230            }
231        });
232    }
233
234    let mut graceful = false; // Will be set to true if the stream closes gracefully
235    // Channel for writing results to file
236    let (tx_r, rx_r) = unbounded_channel();
237
238    // Get protocol and IP version
239    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            // A single protocol type is used
247            first.as_str()
248        } else {
249            // Multiple protocol types are used
250            "multi"
251        }
252    };
253
254    // Determine the file extension based on the output format
255    let mut is_parquet = args.is_parquet;
256
257    let extension = if is_parquet { ".parquet" } else { ".csv.gz" };
258
259    // Get the measurement type prefix
260    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        // Create filename using default convention
276        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    // Create the output file
287    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    // Check if any configuration has an origin ID
308    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    // Check if any configuration sends CHAOS probes
315    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    // Start thread that writes results to file
333    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        } // Stream is exhausted
346        Err(e) => {
347            error!("[CLI] Error receiving message: {e}");
348            break 'mloop;
349        }
350    } {
351        // A default result notifies the CLI that it should not expect any more results
352        if task_result == ReplyBatch::default() {
353            tx_r.send(task_result)?; // Let the results channel know that we are done
354            graceful = true;
355            break;
356        }
357
358        replies_count += task_result.results.len();
359        // Send the results to the file channel
360        tx_r.send(task_result)?;
361    }
362
363    is_done.store(true, Ordering::Relaxed); // Signal the progress bar to stop
364
365    let end = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
366    let length = (end - start) as f32 / 60.0; // Measurement length in minutes
367    info!(
368        "[CLI] Waited {length:.2} minutes for results. Captured {} replies",
369        replies_count.with_separator()
370    );
371
372    // If the stream closed during a measurement
373    if !graceful {
374        tx_r.send(ReplyBatch::default())?; // Let the results channel know that we are done
375        warn!("[CLI] Measurement ended prematurely!");
376    }
377
378    tx_r.closed().await; // Wait for all results to be written to file
379
380    Ok(())
381}
382
383impl CliClient {
384    /// Connect to the orchestrator
385    ///
386    /// # Arguments
387    /// * `address` - the address of the orchestrator (e.g., 10.10.10.10:50051)
388    /// * `tls` - the TLS settings given on the command line
389    ///
390    /// # Returns
391    /// A gRPC client that is connected to the orchestrator
392    ///
393    /// # Remarks
394    /// When TLS is enabled, the connection is secured and the orchestrator authenticated.
395    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        // Create client with secret token that is used to authenticate client commands.
408        let client = ControllerClient::new(channel);
409
410        Ok(client)
411    }
412}