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.
This commit is contained in:
Drew Newberry
2026-03-12 14:11:15 -07:00
parent 50ea4073d6
commit bc248caa49
3 changed files with 59 additions and 24 deletions
+42 -17
View File
@@ -124,34 +124,59 @@ pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result<Docker>
/// 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<String> {
/// 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<u16>) -> Result<String> {
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<String> = 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\
+9 -5
View File
@@ -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<u16>,
) -> 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}"))?;
+8 -2
View File
@@ -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);