Skip to main content

xianyun_cluster/driver/
k3d.rs

1//! Port of `internal/cluster/k3d/driver.go` and `internal/cluster/k3d/ports.go`:
2//! a [`super::Driver`] implementation using k3d as the backend.
3//!
4//! This is the second highest-churn driver file in the Go codebase (51
5//! commits per VCS hotspot analysis, the highest of all), so its tests are
6//! ported with extra care for edge cases in `tests/k3d_driver.rs` and
7//! `tests/k3d_ports.rs`.
8//!
9//! **Executor injection.** Following the pattern established by
10//! `external::Driver` and `colima::Driver` (see their module doc
11//! comments), this driver takes its `Arc<dyn CommandRunner + Send + Sync>`
12//! at construction rather than through an ambient context. [`Driver::new`]
13//! wires in the real `xianyun_executor::exec::Client` so existing callers
14//! (`driver::auto`) need no change; [`Driver::with_runner`] is the
15//! test-facing constructor.
16//!
17//! **`docker.Backend`.** The Go constructor also takes an injected
18//! `docker.Backend` and panics if it's nil, but nothing in `driver.go` or
19//! `ports.go` ever calls a method on it -- grepping the Go source for
20//! `d.docker` or `.docker.` turns up nothing beyond the field assignment
21//! in `NewDriver`. `xianyun-backends::docker` is also still a stub crate
22//! (see `PORT_NOTES.md`), so this port omits the parameter entirely rather
23//! than carry a genuinely dead field forward.
24
25use std::io::Write as _;
26use std::net::TcpStream;
27use std::sync::Arc;
28
29use super::{Driver as DriverTrait, DriverError, State, Status};
30use xianyun_core::config::types::ClusterConfig;
31use xianyun_executor::exec::CommandRunner;
32
33pub const DRIVER_NAME: &str = "k3d";
34
35/// Port of `k3d.Driver`.
36pub struct Driver {
37    pub cfg: ClusterConfig,
38    pub profile_name: String,
39    /// Port of `Driver.portInUse`: the seam tests substitute with a fake
40    /// probe so unit tests never touch the network. Boxed rather than a
41    /// bare `fn` pointer because tests need to capture state (e.g. record
42    /// every address probed).
43    pub port_in_use: Box<dyn Fn(&str) -> bool>,
44    runner: Arc<dyn CommandRunner + Send + Sync>,
45}
46
47impl Driver {
48    /// Port of `k3d.NewDriver`, wired to the real subprocess executor and
49    /// a real TCP-connect port probe (`portprobe.InUse`'s equivalent).
50    #[must_use]
51    pub fn new(cfg: ClusterConfig, profile_name: String) -> Self {
52        Self::with_runner(
53            cfg,
54            profile_name,
55            Arc::new(xianyun_executor::exec::Client::new()),
56        )
57    }
58
59    /// Test-facing constructor: takes the injected `CommandRunner`
60    /// explicitly. `port_in_use` still defaults to the real TCP probe;
61    /// override the field directly (it's `pub`) when a test needs a fake.
62    #[must_use]
63    pub fn with_runner(
64        cfg: ClusterConfig,
65        profile_name: String,
66        runner: Arc<dyn CommandRunner + Send + Sync>,
67    ) -> Self {
68        Self {
69            cfg,
70            profile_name,
71            port_in_use: Box::new(probe_address),
72            runner,
73        }
74    }
75}
76
77impl DriverTrait for Driver {
78    fn name(&self) -> &str {
79        DRIVER_NAME
80    }
81
82    fn start(&self) -> Result<(), DriverError> {
83        let cfg = self.cfg.clone();
84
85        if !is_k3d_available(self) {
86            return Err(DriverError::DriverNotFound("k3d".to_string()));
87        }
88
89        let Some(k3d_cfg) = cfg.k3d.clone() else {
90            return Err(DriverError::MissingDriverConfig);
91        };
92
93        let status = status_impl(self).ok();
94
95        if let Some(status) = &status
96            && status.state == Some(State::Ready)
97        {
98            return verify_config(status, &cfg);
99        }
100
101        if let Some(status) = &status
102            && status.exists
103            && matches!(status.state, Some(State::Stopped) | Some(State::NotReady))
104        {
105            tracing::info!(
106                profile = %self.profile_name,
107                state = ?status.state,
108                "starting existing k3d cluster"
109            );
110
111            let args = vec![
112                "cluster".to_string(),
113                "start".to_string(),
114                self.profile_name.clone(),
115            ];
116
117            return self
118                .runner
119                .exec_streamed_command(None, "k3d", &args)
120                .map_err(|e| DriverError::Other(format!("starting k3d: {e}")));
121        }
122
123        let port_mappings = merge_port_mappings(&k3d_cfg.port_mappings);
124
125        check_port_conflicts(self, &port_mappings)?;
126
127        let args = build_cluster_create_args(self, &cfg, &port_mappings)
128            .map_err(|e| DriverError::Other(format!("building cluster create args: {e}")))?;
129
130        warn_deprecated_disable_args(&k3d_cfg.extra_args);
131
132        tracing::info!(
133            profile = %self.profile_name,
134            agents = k3d_cfg.agents,
135            servers = k3d_cfg.servers,
136            port_mappings = k3d_cfg.port_mappings.len(),
137            extra_args = k3d_cfg.extra_args.len(),
138            "starting k3d cluster"
139        );
140
141        self.runner
142            .exec_streamed_command(None, "k3d", &args)
143            .map_err(|e| DriverError::Other(format!("starting k3d: {e}")))
144    }
145
146    fn stop(&self) -> Result<(), DriverError> {
147        let status = status_impl(self)?;
148
149        if status.state == Some(State::Stopped) {
150            return Ok(());
151        }
152
153        tracing::info!(profile = %self.profile_name, "stopping k3d cluster");
154
155        let args = vec![
156            "cluster".to_string(),
157            "stop".to_string(),
158            self.profile_name.clone(),
159        ];
160
161        self.runner
162            .exec_streamed_command(None, "k3d", &args)
163            .map_err(|e| DriverError::Other(format!("stopping k3d: {e}")))
164    }
165
166    fn destroy(&self) -> Result<(), DriverError> {
167        if !is_k3d_available(self) {
168            return Err(DriverError::DriverNotFound("k3d".to_string()));
169        }
170
171        let status = status_impl(self)?;
172
173        if status.state == Some(State::Stopped) && !status.exists {
174            return Ok(());
175        }
176
177        tracing::info!(profile = %self.profile_name, "destroying k3d cluster");
178
179        let (opts, _guard) = disposable_kubeconfig_opts()
180            .map_err(|e| DriverError::Other(format!("creating disposable kubeconfig: {e}")))?;
181
182        let args = vec![
183            "cluster".to_string(),
184            "delete".to_string(),
185            self.profile_name.clone(),
186        ];
187
188        self.runner
189            .exec_streamed_command(Some(opts), "k3d", &args)
190            .map_err(|e| DriverError::Other(format!("destroying k3d: {e}")))
191    }
192
193    fn status(&self) -> Result<Status, DriverError> {
194        status_impl(self)
195    }
196
197    fn get_kubeconfig(&self) -> Result<String, DriverError> {
198        if !is_k3d_available(self) {
199            return Err(DriverError::DriverNotFound("k3d".to_string()));
200        }
201
202        let status = status_impl(self)?;
203
204        if status.state != Some(State::Ready) {
205            return Err(DriverError::ClusterNotRunning);
206        }
207
208        let args = vec![
209            "kubeconfig".to_string(),
210            "get".to_string(),
211            self.profile_name.clone(),
212        ];
213
214        self.runner
215            .read_stdout("k3d", &args)
216            .map_err(|e| DriverError::Other(format!("reading kubeconfig: {e}")))
217    }
218}
219
220/// An empty temp file created for [`disposable_kubeconfig_opts`], deleted
221/// on drop.
222struct TempKubeconfigGuard(std::path::PathBuf);
223
224impl Drop for TempKubeconfigGuard {
225    fn drop(&mut self) {
226        let _ = std::fs::remove_file(&self.0);
227    }
228}
229
230/// Creates a throwaway temp file to stand in for `kubeconfig.Create("")`
231/// (out of scope for this session -- `kubeconfig` is owned by a parallel
232/// worktree, and `tempfile` is only a dev-dependency of this crate).
233/// Returns exec options with `KUBECONFIG` pointed at it and a guard that
234/// deletes the file on drop.
235fn disposable_kubeconfig_opts() -> std::io::Result<(
236    xianyun_executor::exec::ExecStreamedOpts,
237    TempKubeconfigGuard,
238)> {
239    let pid = std::process::id();
240    let nanos = std::time::SystemTime::now()
241        .duration_since(std::time::UNIX_EPOCH)
242        .map(|d| d.as_nanos())
243        .unwrap_or_default();
244    let path = std::env::temp_dir().join(format!("caproni-kubeconfig-{pid}-{nanos}"));
245
246    let mut file = std::fs::File::create(&path)?;
247    file.write_all(b"")?;
248
249    let mut env: Vec<String> = std::env::vars().map(|(k, v)| format!("{k}={v}")).collect();
250    env.push(format!("KUBECONFIG={}", path.display()));
251
252    Ok((
253        xianyun_executor::exec::ExecStreamedOpts {
254            use_scrolling_area: true,
255            env: Some(env),
256            ..Default::default()
257        },
258        TempKubeconfigGuard(path),
259    ))
260}
261
262fn status_impl(driver: &Driver) -> Result<Status, DriverError> {
263    if !is_k3d_available(driver) {
264        return Err(DriverError::DriverNotFound("k3d".to_string()));
265    }
266
267    let mut status = Status {
268        driver: DRIVER_NAME.to_string(),
269        state: Some(State::Stopped),
270        kube_context: format!("k3d-{}", driver.profile_name),
271        ..Default::default()
272    };
273
274    let args = vec![
275        "cluster".to_string(),
276        "list".to_string(),
277        driver.profile_name.clone(),
278        "-o".to_string(),
279        "json".to_string(),
280    ];
281
282    let stdout = match driver.runner.read_stdout("k3d", &args) {
283        Ok(out) => out,
284        Err(_) => return Ok(status),
285    };
286
287    let clusters: Vec<K3dCluster> = serde_json::from_str(&stdout)
288        .map_err(|e| DriverError::Other(format!("parsing k3d cluster list JSON: {e}")))?;
289
290    let Some(target) = clusters.iter().find(|c| c.name == driver.profile_name) else {
291        return Ok(status);
292    };
293
294    status.exists = true;
295    status.kubernetes_version = node_version(&target.nodes);
296    status.ip_address = node_ip(&target.nodes);
297    status.host_ip = host_ip(std::env::consts::OS, &status.ip_address);
298
299    status.state = if target.servers_running == 0 {
300        Some(State::Stopped)
301    } else if target.servers_running == target.servers_count {
302        Some(State::Ready)
303    } else {
304        Some(State::NotReady)
305    };
306
307    Ok(status)
308}
309
310/// Port of `k3d.hostIP`.
311#[must_use]
312pub fn host_ip(goos: &str, ip: &str) -> String {
313    if goos == "darwin" {
314        "127.0.0.1".to_string()
315    } else {
316        ip.to_string()
317    }
318}
319
320/// Port of `(*k3d.Driver).isK3dAvailable`.
321pub fn is_k3d_available(driver: &Driver) -> bool {
322    driver.runner.look_path("k3d").is_ok()
323}
324
325/// Port of `(*k3d.Driver).verifyConfig`.
326pub fn verify_config(status: &Status, _cfg: &ClusterConfig) -> Result<(), DriverError> {
327    if status.driver != DRIVER_NAME {
328        return Err(DriverError::ConfigMismatch);
329    }
330
331    Ok(())
332}
333
334/// Port of `k3d.nodeVersion`.
335#[must_use]
336pub fn node_version(nodes: &[K3dNode]) -> String {
337    for node in nodes {
338        if node.role == "server" {
339            return node
340                .runtime_labels
341                .get("k3d.version")
342                .cloned()
343                .unwrap_or_default();
344        }
345    }
346
347    String::new()
348}
349
350/// Port of `k3d.nodeIP`.
351#[must_use]
352pub fn node_ip(nodes: &[K3dNode]) -> String {
353    let mut server_ip = String::new();
354
355    for node in nodes {
356        if node.role == "loadbalancer" && !node.ip.ip.is_empty() {
357            return node.ip.ip.clone();
358        }
359        if node.role == "server" && !node.ip.ip.is_empty() && server_ip.is_empty() {
360            server_ip = node.ip.ip.clone();
361        }
362    }
363
364    server_ip
365}
366
367/// Matches an `extra_args` entry that turns off a packaged k3s component,
368/// in either form k3d accepts: joined onto `--k3s-arg`, or as the token
369/// that follows it. Port of `k3d.deprecatedDisableArg`.
370fn is_deprecated_disable_arg(arg: &str) -> bool {
371    if let Some(rest) = arg.strip_prefix("--disable") {
372        return rest.starts_with('=') || rest.starts_with(' ');
373    }
374    if let Some(idx) = arg.find("=--disable") {
375        let rest = &arg[idx + "=--disable".len()..];
376        return rest.starts_with('=') || rest.starts_with(' ');
377    }
378    false
379}
380
381/// Port of `k3d.warnDeprecatedDisableArgs`.
382pub fn warn_deprecated_disable_args(extra_args: &[String]) {
383    for arg in extra_args {
384        if !is_deprecated_disable_arg(arg) {
385            continue;
386        }
387
388        tracing::warn!(
389            extra_arg = %arg,
390            "disabling k3s components through extra_args is deprecated and is invisible to \
391            caproni; declare it under cluster.k3d.k3s.disable instead"
392        );
393    }
394}
395
396/// Port of `(*k3d.Driver).buildClusterCreateArgs`.
397pub fn build_cluster_create_args(
398    driver: &Driver,
399    cfg: &ClusterConfig,
400    port_mappings: &[PortMapping],
401) -> Result<Vec<String>, DriverError> {
402    let k3d_cfg = cfg.k3d.as_ref().ok_or(DriverError::MissingDriverConfig)?;
403
404    let mut args = vec![
405        "cluster".to_string(),
406        "create".to_string(),
407        driver.profile_name.clone(),
408        "--k3s-arg".to_string(),
409        "--kubelet-arg=feature-gates=KubeletInUserNamespace=true@server:*".to_string(),
410        "--kubeconfig-update-default=false".to_string(),
411    ];
412
413    if k3d_cfg.agents > 0 {
414        args.push("--agents".to_string());
415        args.push(k3d_cfg.agents.to_string());
416    }
417
418    if k3d_cfg.servers > 0 && k3d_cfg.servers != 1 {
419        args.push("--servers".to_string());
420        args.push(k3d_cfg.servers.to_string());
421    }
422
423    if !k3d_cfg.image.is_empty() {
424        args.push("--image".to_string());
425        args.push(k3d_cfg.image.clone());
426    }
427
428    for mapping in port_mappings {
429        args.push("-p".to_string());
430        args.push(mapping.as_spec().to_string());
431    }
432
433    if let Some(mirror) = &cfg.registry_mirror {
434        let path = write_registries_yaml(&driver.profile_name, mirror)
435            .map_err(|e| DriverError::Other(format!("writing k3d registries.yaml: {e}")))?;
436        args.push("--registry-config".to_string());
437        args.push(path);
438    }
439
440    if let Some(network) = &cfg.network
441        && !network.cluster_domain.is_empty()
442    {
443        args.push("--k3s-arg".to_string());
444        args.push(format!(
445            "--cluster-domain={}@server:*",
446            network.cluster_domain
447        ));
448    }
449
450    if let Some(k3s) = &k3d_cfg.k3s {
451        for component in &k3s.disable {
452            args.push("--k3s-arg".to_string());
453            args.push(format!("--disable={component}@server:*"));
454        }
455    }
456
457    args.extend(k3d_cfg.extra_args.iter().cloned());
458
459    Ok(args)
460}
461
462/// Port of `k3d.writeRegistriesYAML`. Resolves the registries.yaml path
463/// directly under `$HOME/.local/share/caproni/<profile>` rather than
464/// through the out-of-scope `xianyun-executor::datadir` module's
465/// ambient-HOME entry point (see `colima::certs_dir`'s identical
466/// deviation).
467fn write_registries_yaml(
468    profile: &str,
469    mirror: &xianyun_core::config::types::RegistryMirrorConfig,
470) -> std::io::Result<String> {
471    let home = std::env::var("HOME")
472        .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string()))?;
473
474    let dir = std::path::PathBuf::from(home)
475        .join(".local/share/caproni")
476        .join(profile);
477    std::fs::create_dir_all(&dir)?;
478
479    let path = dir.join("k3d-registries.yaml");
480
481    let endpoint = mirror.host_port();
482    let endpoint_url = format!("http://{endpoint}");
483
484    let mut mirrors = serde_yaml::Mapping::new();
485    for upstream in &mirror.upstreams {
486        let mut entry = serde_yaml::Mapping::new();
487        entry.insert(
488            serde_yaml::Value::String("endpoint".to_string()),
489            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(endpoint_url.clone())]),
490        );
491        mirrors.insert(
492            serde_yaml::Value::String(upstream.clone()),
493            serde_yaml::Value::Mapping(entry),
494        );
495    }
496
497    let mut tls = serde_yaml::Mapping::new();
498    tls.insert(
499        serde_yaml::Value::String("insecureSkipVerify".to_string()),
500        serde_yaml::Value::Bool(true),
501    );
502    let mut config_entry = serde_yaml::Mapping::new();
503    config_entry.insert(
504        serde_yaml::Value::String("tls".to_string()),
505        serde_yaml::Value::Mapping(tls),
506    );
507    let mut configs = serde_yaml::Mapping::new();
508    configs.insert(
509        serde_yaml::Value::String(endpoint),
510        serde_yaml::Value::Mapping(config_entry),
511    );
512
513    let mut root = serde_yaml::Mapping::new();
514    root.insert(
515        serde_yaml::Value::String("mirrors".to_string()),
516        serde_yaml::Value::Mapping(mirrors),
517    );
518    root.insert(
519        serde_yaml::Value::String("configs".to_string()),
520        serde_yaml::Value::Mapping(configs),
521    );
522
523    let body = serde_yaml::to_string(&serde_yaml::Value::Mapping(root))
524        .map_err(|e| std::io::Error::other(e.to_string()))?;
525
526    std::fs::write(&path, body)?;
527
528    Ok(path.to_string_lossy().into_owned())
529}
530
531/// Port of `k3d.k3dCluster`, the JSON shape of `k3d cluster list -o json`.
532#[derive(Debug, Clone, Default, serde::Deserialize)]
533#[serde(rename_all = "camelCase", default)]
534pub struct K3dCluster {
535    pub name: String,
536    pub servers_count: i32,
537    pub servers_running: i32,
538    pub agents_count: i32,
539    pub agents_running: i32,
540    pub nodes: Vec<K3dNode>,
541}
542
543/// Port of `k3d.k3dNode`.
544#[derive(Debug, Clone, Default, serde::Deserialize)]
545#[serde(default)]
546pub struct K3dNode {
547    pub name: String,
548    pub role: String,
549    #[serde(rename = "IP")]
550    pub ip: K3dNodeIp,
551    #[serde(rename = "State")]
552    pub state: K3dNodeState,
553    #[serde(rename = "runtimeLabels")]
554    pub runtime_labels: std::collections::HashMap<String, String>,
555}
556
557/// Port of `k3d.k3dNodeIP`.
558#[derive(Debug, Clone, Default, serde::Deserialize)]
559pub struct K3dNodeIp {
560    #[serde(rename = "IP", default)]
561    pub ip: String,
562    #[serde(rename = "Static", default)]
563    pub static_: bool,
564}
565
566/// Port of `k3d.k3dNodeState`.
567#[derive(Debug, Clone, Default, serde::Deserialize)]
568pub struct K3dNodeState {
569    #[serde(rename = "Running", default)]
570    pub running: bool,
571    #[serde(rename = "Status", default)]
572    pub status: String,
573}
574
575/// Port of `k3d.portMapping`: a single `k3d cluster create -p` spec, parsed
576/// once when it enters the driver and passed around in that form.
577#[derive(Debug, Clone, Default, PartialEq, Eq)]
578pub struct PortMapping {
579    pub raw: String,
580    pub host_ip: String,
581    pub host_port: String,
582    pub container_port: String,
583    pub tcp: bool,
584}
585
586impl PortMapping {
587    /// Port of `(portMapping).String`.
588    #[must_use]
589    pub fn as_spec(&self) -> &str {
590        &self.raw
591    }
592
593    /// Port of `(portMapping).hostAddress`.
594    #[must_use]
595    pub fn host_address(&self) -> Option<String> {
596        if self.host_port.is_empty() || !self.tcp {
597            return None;
598        }
599
600        let host = if self.host_ip.is_empty() || self.host_ip == "0.0.0.0" {
601            "127.0.0.1"
602        } else {
603            &self.host_ip
604        };
605
606        Some(join_host_port(host, &self.host_port))
607    }
608
609    /// Port of `(portMapping).tcpContainerPort`.
610    #[must_use]
611    pub fn tcp_container_port(&self) -> Option<&str> {
612        if !self.tcp {
613            return None;
614        }
615
616        Some(&self.container_port)
617    }
618}
619
620/// Port of `net.JoinHostPort` for the plain (non-IPv6-bracket-needing)
621/// hosts this driver ever probes -- every mapping with a bracketed IPv6
622/// host fails to parse upstream (see [`parse_port_mapping`]) and is never
623/// probed in the first place.
624fn join_host_port(host: &str, port: &str) -> String {
625    format!("{host}:{port}")
626}
627
628/// Port of `k3d.parsePortMappings`.
629#[must_use]
630pub fn parse_port_mappings(mappings: &[String]) -> Vec<PortMapping> {
631    mappings.iter().map(|m| parse_port_mapping(m)).collect()
632}
633
634/// Port of `k3d.parsePortMapping`.
635#[must_use]
636pub fn parse_port_mapping(mapping: &str) -> PortMapping {
637    let parsed = PortMapping {
638        raw: mapping.to_string(),
639        ..Default::default()
640    };
641
642    let spec = mapping.split('@').next().unwrap_or(mapping);
643    let (spec, protocol, has_protocol) = match spec.split_once('/') {
644        Some((spec, protocol)) => (spec, protocol, true),
645        None => (spec, "", false),
646    };
647
648    let fields: Vec<&str> = spec.split(':').collect();
649
650    let (host_ip, host_port, container_port) = match fields.as_slice() {
651        [container_port] => (String::new(), String::new(), (*container_port).to_string()),
652        [host_port, container_port] => (
653            String::new(),
654            (*host_port).to_string(),
655            (*container_port).to_string(),
656        ),
657        [host_ip, host_port, container_port] => (
658            (*host_ip).to_string(),
659            (*host_port).to_string(),
660            (*container_port).to_string(),
661        ),
662        _ => return parsed,
663    };
664
665    if !is_port_number(&container_port) {
666        return parsed;
667    }
668
669    if !host_port.is_empty() && !is_port_number(&host_port) {
670        return parsed;
671    }
672
673    PortMapping {
674        raw: mapping.to_string(),
675        host_ip,
676        host_port,
677        container_port,
678        tcp: !has_protocol || protocol == "tcp",
679    }
680}
681
682fn is_port_number(s: &str) -> bool {
683    match s.parse::<u32>() {
684        Ok(port) => port > 0 && port <= u32::from(u16::MAX),
685        Err(_) => false,
686    }
687}
688
689/// Port of `k3d.mergePortMappings`.
690#[must_use]
691pub fn merge_port_mappings(user_mappings: &[String]) -> Vec<PortMapping> {
692    let defaults = parse_port_mappings(&[
693        "80:80@loadbalancer".to_string(),
694        "443:443@loadbalancer".to_string(),
695    ]);
696    let user = parse_port_mappings(user_mappings);
697
698    let overridden: std::collections::HashSet<String> = user
699        .iter()
700        .filter_map(|m| m.tcp_container_port().map(str::to_string))
701        .collect();
702
703    let mut result = Vec::with_capacity(defaults.len() + user.len());
704
705    for def in defaults {
706        let is_overridden = def
707            .tcp_container_port()
708            .is_some_and(|p| overridden.contains(p));
709        if !is_overridden {
710            result.push(def);
711        }
712    }
713
714    result.extend(user);
715    result
716}
717
718/// Port of `(*k3d.Driver).checkPortConflicts`.
719pub fn check_port_conflicts(driver: &Driver, mappings: &[PortMapping]) -> Result<(), DriverError> {
720    let mut conflicts = Vec::new();
721
722    for mapping in mappings {
723        let Some(address) = mapping.host_address() else {
724            continue;
725        };
726
727        if !(driver.port_in_use)(&address) {
728            continue;
729        }
730
731        tracing::debug!(mapping = mapping.as_spec(), address = %address, "host port already has a listener");
732
733        conflicts.push(address);
734    }
735
736    if conflicts.is_empty() {
737        return Ok(());
738    }
739
740    Err(DriverError::PortInUse(format!(
741        "{}; stop whatever is listening there, or publish the cluster on other host ports with \
742        cluster.k3d.port_mappings",
743        conflicts.join(", ")
744    )))
745}
746
747/// Port of `k3d.probeAddress` (the seam `Driver.portInUse` is stubbed by
748/// tests): dials `address` over TCP and reports whether a listener
749/// answered.
750#[must_use]
751pub fn probe_address(address: &str) -> bool {
752    TcpStream::connect(address).is_ok()
753}