feat(cli): add multi-target probing support and labels

Enable concurrent probing of multiple targets using positional arguments or the `--target` flag with unique labels. Update output formats to include a `target` column for logical attribution in multi-target runs.
This commit is contained in:
Nils Andreas Svee committed 2026-06-23 21:26:46 +07:00
1 parent a8622ee15a
commit 69fd24ce64
5 files changed
+930 -76

No files matched your search

+264 -10
View File
@@ -1,7 +1,7 @@
use std::time::Duration;
use std::{collections::HashSet, net::ToSocketAddrs, time::Duration};
use clap::{Parser, ValueEnum};
use irtt_client::ClientConfig;
use irtt_client::{ClientConfig, ManagedGroupPacing, ManagedTargetConfig, TargetId};
use crate::shared::client::{parse_test_duration, CommonClientArgs, TimestampArg};
@@ -10,9 +10,31 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address or host, with optional port.
#[arg(required_unless_present = "list_columns")]
pub server: Option<String>,
/// Server address or host, with optional port. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 0..,
long_help = "Server address or host, with optional port. Repeat the positional target for multi-target mode, for example: irtt-cli host-a:2112 host-b:2112."
)]
pub targets: Vec<String>,
/// Explicit labelled target, as label=host:port. Repeat for multi-target mode.
#[arg(
long = "target",
value_name = "LABEL=TARGET",
value_parser = parse_labelled_target,
long_help = "Explicit labelled target, as label=host:port. Repeat for multi-target mode. The label is used as the target column and must be unique."
)]
pub labelled_targets: Vec<LabelledTargetArg>,
/// Managed group pacing for multi-target mode.
#[arg(
long,
value_enum,
default_value_t = GroupPacingArg::Staggered,
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
)]
pub pacing: GroupPacingArg,
#[arg(
long,
@@ -68,9 +90,8 @@ pub struct ClientArgs {
impl ClientArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(
self.server
.as_deref()
.expect("server is required unless --list-columns is set"),
self.primary_target_addr()
.expect("target is required unless --list-columns is set"),
self.duration,
)
}
@@ -82,6 +103,82 @@ impl ClientArgs {
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
pub fn target_specs(&self) -> Result<Vec<CliTargetSpec>, String> {
let mut specs = Vec::new();
let mut positional_counts = std::collections::HashMap::<&str, usize>::new();
for target in &self.targets {
let count = positional_counts.entry(target.as_str()).or_default();
*count += 1;
let label = if *count == 1 {
target.clone()
} else {
format!("{target}#{}", *count)
};
specs.push(CliTargetSpec {
label,
addr: target.clone(),
});
}
for target in &self.labelled_targets {
specs.push(CliTargetSpec {
label: target.label.clone(),
addr: target.addr.clone(),
});
}
if specs.is_empty() {
return Err("at least one target is required unless --list-columns is set".to_owned());
}
let mut labels = HashSet::new();
for spec in &specs {
if !labels.insert(spec.label.clone()) {
return Err(format!("duplicate target label {:?}", spec.label));
}
}
Ok(specs)
}
pub fn resolved_managed_targets(&self) -> Result<Vec<ResolvedCliTarget>, String> {
let specs = self.target_specs()?;
let config = self.to_client_config();
let mut remotes = HashSet::new();
let mut targets = Vec::with_capacity(specs.len());
for spec in specs {
let remote = resolve_cli_target(&spec.addr, &config).map_err(|err| {
format!(
"failed to resolve target {} ({:?}): {err}",
spec.label, spec.addr
)
})?;
if !remotes.insert(remote) {
return Err(format!(
"duplicate resolved target address {remote} for label {}",
spec.label
));
}
targets.push(ResolvedCliTarget {
label: spec.label.clone(),
managed: ManagedTargetConfig {
id: TargetId::from(spec.label),
remote,
auth: None,
},
});
}
Ok(targets)
}
fn primary_target_addr(&self) -> Option<&str> {
self.targets.first().map(String::as_str).or_else(|| {
self.labelled_targets
.first()
.map(|target| target.addr.as_str())
})
}
}
impl std::ops::Deref for ClientArgs {
@@ -104,6 +201,39 @@ pub enum OutputFormat {
Jsonl,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LabelledTargetArg {
pub label: String,
pub addr: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliTargetSpec {
pub label: String,
pub addr: String,
}
#[derive(Debug, Clone)]
pub struct ResolvedCliTarget {
pub label: String,
pub managed: ManagedTargetConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GroupPacingArg {
Staggered,
Burst,
}
impl From<GroupPacingArg> for ManagedGroupPacing {
fn from(value: GroupPacingArg) -> Self {
match value {
GroupPacingArg::Staggered => Self::Staggered,
GroupPacingArg::Burst => Self::Burst,
}
}
}
impl OutputFormat {
pub fn prints_summary(self) -> bool {
matches!(self, Self::Table)
@@ -120,6 +250,58 @@ pub enum HeaderMode {
Never,
}
fn parse_labelled_target(input: &str) -> Result<LabelledTargetArg, String> {
let (label, addr) = input
.split_once('=')
.ok_or_else(|| "target must use LABEL=TARGET syntax".to_owned())?;
if label.is_empty() {
return Err("target label must not be empty".to_owned());
}
if addr.is_empty() {
return Err("target address must not be empty".to_owned());
}
Ok(LabelledTargetArg {
label: label.to_owned(),
addr: addr.to_owned(),
})
}
fn resolve_cli_target(addr: &str, config: &ClientConfig) -> Result<std::net::SocketAddr, String> {
let normalized = normalize_target_addr(addr);
let mut addrs = normalized
.to_socket_addrs()
.map_err(|_| format!("failed to resolve address {normalized:?}"))?;
addrs
.find(|addr| {
(!config.socket_config.ipv4_only || addr.is_ipv4())
&& (!config.socket_config.ipv6_only || addr.is_ipv6())
})
.ok_or_else(|| format!("failed to resolve address {normalized:?}"))
}
fn normalize_target_addr(addr: &str) -> String {
const DEFAULT_PORT: u16 = 2112;
if addr.parse::<std::net::SocketAddr>().is_ok() {
return addr.to_owned();
}
if addr.starts_with('[') && addr.ends_with(']') {
return format!("{addr}:{DEFAULT_PORT}");
}
if addr.starts_with('[') {
return addr.to_owned();
}
if addr.parse::<std::net::Ipv6Addr>().is_ok() {
return format!("[{addr}]:{DEFAULT_PORT}");
}
if addr
.rsplit_once(':')
.is_some_and(|(_, port)| port.parse::<u16>().is_ok())
{
return addr.to_owned();
}
format!("{addr}:{DEFAULT_PORT}")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -137,7 +319,9 @@ mod tests {
fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server.as_deref(), Some("127.0.0.1:2112"));
assert_eq!(args.targets, ["127.0.0.1:2112"]);
assert!(args.labelled_targets.is_empty());
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.format, OutputFormat::Table);
@@ -165,14 +349,84 @@ mod tests {
#[test]
fn list_columns_does_not_require_server() {
let args = parse(&["--list-columns"]).unwrap();
assert_eq!(args.server, None);
assert!(args.targets.is_empty());
assert!(args.list_columns);
}
#[test]
fn multiple_positional_targets_parse_as_targets() {
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[0].addr, "host-a:2112");
assert_eq!(specs[1].label, "host-b:2112");
assert_eq!(specs[1].addr, "host-b:2112");
}
#[test]
fn repeated_labelled_targets_parse() {
let args = parse(&[
"--target",
"ams=ams.example.com:2112",
"--target",
"sg=sg.example.com:2112",
])
.unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "ams");
assert_eq!(specs[0].addr, "ams.example.com:2112");
assert_eq!(specs[1].label, "sg");
assert_eq!(specs[1].addr, "sg.example.com:2112");
}
#[test]
fn duplicate_labels_are_rejected() {
let args = parse(&["host-a:2112", "--target", "host-a:2112=host-b:2112"]).unwrap();
let err = args.target_specs().unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn duplicate_positional_target_strings_get_stable_suffixes() {
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
let specs = args.target_specs().unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
}
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["--target", "missing-equals"]).is_err());
assert!(parse(&["--target", "=127.0.0.1:2112"]).is_err());
assert!(parse(&["--target", "label="]).is_err());
}
#[test]
fn pacing_option_accepts_supported_values() {
assert_eq!(
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Staggered
);
assert_eq!(
parse(&["--pacing", "burst", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Burst
);
}
#[test]
fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--format <FORMAT>"));
assert!(help.contains("--target <LABEL=TARGET>"));
assert!(help.contains("--pacing <PACING>"));
assert!(help.contains("--columns <COLUMNS>"));
assert!(help.contains("--list-columns"));
assert!(help.contains("--header <HEADER>"));