From bc248caa49523261ad3631a4485ce0649d7c8a1f Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 12 Mar 2026 14:11:15 -0700 Subject: [PATCH] feat(gateway): select container by host port when multiple gateways exist When the endpoint URL includes a port, use it to narrow container discovery by matching against Docker host port bindings. This correctly disambiguates when multiple openshell gateway containers run on the same Docker host. --- crates/navigator-bootstrap/src/docker.rs | 59 +++++++++++++++++------- crates/navigator-bootstrap/src/lib.rs | 14 ++++-- crates/navigator-cli/src/run.rs | 10 +++- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/crates/navigator-bootstrap/src/docker.rs b/crates/navigator-bootstrap/src/docker.rs index 33f949317..cddb049ba 100644 --- a/crates/navigator-bootstrap/src/docker.rs +++ b/crates/navigator-bootstrap/src/docker.rs @@ -124,34 +124,59 @@ pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result /// Find the running openshell gateway container by image name. /// /// Lists all running containers and returns the name of the one whose image -/// contains `openshell/cluster`. Fails if zero or multiple containers match. -pub async fn find_gateway_container(docker: &Docker) -> Result { +/// contains `openshell/cluster`. When `port` is provided, only containers +/// with a matching host port binding are considered — this disambiguates +/// when multiple gateway containers are running on the same host. +/// +/// Fails if zero or multiple containers match. +pub async fn find_gateway_container(docker: &Docker, port: Option) -> Result { let containers = docker .list_containers(Some(ListContainersOptionsBuilder::new().all(false).build())) .await .into_diagnostic() .wrap_err("failed to list Docker containers")?; + let is_gateway_image = |c: &bollard::models::ContainerSummary| { + c.image + .as_deref() + .is_some_and(|img| img.contains("openshell/cluster")) + }; + + let has_port = |c: &bollard::models::ContainerSummary, p: u16| { + c.ports + .as_deref() + .unwrap_or_default() + .iter() + .any(|binding| binding.public_port == Some(p)) + }; + + let container_name = |c: &bollard::models::ContainerSummary| { + c.names + .as_ref() + .and_then(|n| n.first()) + .map(|n| n.trim_start_matches('/').to_string()) + }; + let matches: Vec = containers .iter() - .filter(|c| { - c.image - .as_deref() - .is_some_and(|img| img.contains("openshell/cluster")) - }) - .filter_map(|c| { - c.names - .as_ref() - .and_then(|n| n.first()) - .map(|n| n.trim_start_matches('/').to_string()) - }) + .filter(|c| is_gateway_image(c) && port.map_or(true, |p| has_port(c, p))) + .filter_map(container_name) .collect(); match matches.len() { - 0 => Err(miette::miette!( - "No openshell gateway container found.\n\ - Is the gateway running? Check with: docker ps" - )), + 0 => { + let hint = if let Some(p) = port { + format!( + "No openshell gateway container found listening on port {p}.\n\ + Is the gateway running? Check with: docker ps" + ) + } else { + "No openshell gateway container found.\n\ + Is the gateway running? Check with: docker ps" + .to_string() + }; + Err(miette::miette!("{hint}")) + } 1 => Ok(matches.into_iter().next().unwrap()), _ => Err(miette::miette!( "Found multiple openshell gateway containers: {}\n\ diff --git a/crates/navigator-bootstrap/src/lib.rs b/crates/navigator-bootstrap/src/lib.rs index a537adc3f..b8c0cdddd 100644 --- a/crates/navigator-bootstrap/src/lib.rs +++ b/crates/navigator-bootstrap/src/lib.rs @@ -483,15 +483,19 @@ pub async fn gateway_handle(name: &str, remote: Option<&RemoteOptions>) -> Resul /// them locally so the CLI can connect. /// /// Connects to Docker (local or remote via SSH), auto-discovers the running -/// gateway container by image name, reads the PKI bundle from Kubernetes -/// secrets inside it, and writes the client materials (ca.crt, tls.crt, -/// tls.key) to the gateway config directory. -pub async fn extract_and_store_pki(name: &str, remote: Option<&RemoteOptions>) -> Result<()> { +/// gateway container by image name (narrowed by `port` when provided), reads +/// the PKI bundle from Kubernetes secrets inside it, and writes the client +/// materials (ca.crt, tls.crt, tls.key) to the gateway config directory. +pub async fn extract_and_store_pki( + name: &str, + remote: Option<&RemoteOptions>, + port: Option, +) -> Result<()> { let docker = match remote { Some(r) => create_ssh_docker_client(r).await?, None => Docker::connect_with_local_defaults().into_diagnostic()?, }; - let cname = docker::find_gateway_container(&docker).await?; + let cname = docker::find_gateway_container(&docker, port).await?; let bundle = load_existing_pki_bundle(&docker, &cname, constants::KUBECONFIG_PATH) .await .map_err(|e| miette::miette!("Failed to extract TLS certificates: {e}"))?; diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index cd34af1f1..4301d633e 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -955,9 +955,15 @@ pub async fn gateway_add( }); // Extract certs BEFORE storing metadata — if this fails the gateway - // is not registered. + // is not registered. Pass the endpoint port so the container can be + // identified by its host port binding when multiple gateways run on + // the same Docker host. + let endpoint_port = url::Url::parse(&endpoint) + .ok() + .and_then(|u| u.port()); eprintln!("• Extracting TLS certificates from gateway container..."); - navigator_bootstrap::extract_and_store_pki(name, remote_opts.as_ref()).await?; + navigator_bootstrap::extract_and_store_pki(name, remote_opts.as_ref(), endpoint_port) + .await?; let (remote_host, resolved_host) = if let Some(dest) = remote { let ssh_host = extract_host_from_ssh_destination(dest);