Skip to content
Open
2 changes: 2 additions & 0 deletions crates/openshell-bootstrap/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub const SERVER_TLS_SECRET_NAME: &str = "openshell-server-tls";
pub const SERVER_CLIENT_CA_SECRET_NAME: &str = "openshell-server-client-ca";
/// K8s secret holding the client TLS certificate, key, and CA cert (shared by CLI and sandboxes).
pub const CLIENT_TLS_SECRET_NAME: &str = "openshell-client-tls";
/// K8s secret holding the SSH handshake HMAC secret (shared by gateway and sandbox pods).
pub const SSH_HANDSHAKE_SECRET_NAME: &str = "openshell-ssh-handshake";

pub fn container_name(name: &str) -> String {
format!("openshell-cluster-{name}")
Expand Down
155 changes: 151 additions & 4 deletions crates/openshell-bootstrap/src/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ use bollard::API_DEFAULT_VERSION;
use bollard::Docker;
use bollard::errors::Error as BollardError;
use bollard::models::{
ContainerCreateBody, DeviceRequest, HostConfig, HostConfigCgroupnsModeEnum,
NetworkCreateRequest, NetworkDisconnectRequest, PortBinding, VolumeCreateRequest,
ContainerCreateBody, DeviceRequest, EndpointSettings, HostConfig, HostConfigCgroupnsModeEnum,
NetworkConnectRequest, NetworkCreateRequest, NetworkDisconnectRequest, PortBinding,
RestartPolicy, RestartPolicyNameEnum, VolumeCreateRequest,
};
use bollard::query_parameters::{
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, InspectNetworkOptions,
Expand Down Expand Up @@ -481,6 +482,12 @@ pub async fn ensure_container(
) -> Result<()> {
let container_name = container_name(name);

// When an existing container is recreated due to an image change, we
// preserve its hostname so the new container registers with the same k3s
// node identity. Without this, k3s sees a brand-new node while pods on
// the old (now-dead) node remain stuck in Terminating.
let mut preserved_hostname: Option<String> = None;

// Check if the container already exists
match docker
.inspect_container(&container_name, None::<InspectContainerOptions>)
Expand All @@ -505,15 +512,35 @@ pub async fn ensure_container(
};

if image_matches {
// The container exists with the correct image, but its network
// attachment may be stale. When the gateway is resumed after a
// container kill, `ensure_network` destroys and recreates the
// Docker network (giving it a new ID). The stopped container
// still references the old network ID, so `docker start` would
// fail with "network <old-id> not found".
//
// Fix: disconnect from any existing networks and reconnect to
// the current (just-created) network before returning.
let expected_net = network_name(name);
reconcile_container_network(docker, &container_name, &expected_net).await?;
return Ok(());
}

// Image changed — remove the stale container so we can recreate it
// Image changed — remove the stale container so we can recreate it.
// Capture the hostname before removal so the replacement container
// keeps the same k3s node identity.
preserved_hostname = info
.config
.as_ref()
.and_then(|c| c.hostname.clone())
.filter(|h| !h.is_empty());

tracing::info!(
"Container {} exists but uses a different image (container={}, desired={}), recreating",
"Container {} exists but uses a different image (container={}, desired={}), recreating (preserving hostname {:?})",
container_name,
container_image_id.as_deref().map_or("unknown", truncate_id),
desired_id.as_deref().map_or("unknown", truncate_id),
preserved_hostname,
);

let _ = docker.stop_container(&container_name, None).await;
Expand Down Expand Up @@ -555,6 +582,12 @@ pub async fn ensure_container(
port_bindings: Some(port_bindings),
binds: Some(vec![format!("{}:/var/lib/rancher/k3s", volume_name(name))]),
network_mode: Some(network_name(name)),
// Automatically restart the container when Docker restarts, unless the
// user explicitly stopped it with `gateway stop`.
restart_policy: Some(RestartPolicy {
name: Some(RestartPolicyNameEnum::UNLESS_STOPPED),
maximum_retry_count: None,
}),
// Add host gateway aliases for DNS resolution.
// This allows both the entrypoint script and the running gateway
// process to reach services on the Docker host.
Expand Down Expand Up @@ -714,7 +747,14 @@ pub async fn ensure_container(

let env = Some(env_vars);

// Use the preserved hostname from a previous container (image-change
// recreation) so k3s keeps the same node identity. For fresh containers
// fall back to the Docker container name, giving a stable hostname that
// survives future image-change recreations.
let hostname = preserved_hostname.unwrap_or_else(|| container_name.clone());

let config = ContainerCreateBody {
hostname: Some(hostname),
image: Some(image_ref.to_string()),
cmd: Some(cmd),
env,
Expand Down Expand Up @@ -956,6 +996,48 @@ pub async fn destroy_gateway_resources(docker: &Docker, name: &str) -> Result<()
Ok(())
}

/// Clean up the gateway container and network, preserving the persistent volume.
///
/// Used when a resume attempt fails — we want to remove the container we may
/// have just created but keep the volume so the user can retry without losing
/// their k3s/etcd state and sandbox data.
pub async fn cleanup_gateway_container(docker: &Docker, name: &str) -> Result<()> {
let container_name = container_name(name);
let net_name = network_name(name);

// Disconnect container from network
let _ = docker
.disconnect_network(
&net_name,
NetworkDisconnectRequest {
container: container_name.clone(),
force: Some(true),
},
)
.await;

let _ = stop_container(docker, &container_name).await;

let remove_container = docker
.remove_container(
&container_name,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
if let Err(err) = remove_container
&& !is_not_found(&err)
{
return Err(err).into_diagnostic();
}

force_remove_network(docker, &net_name).await?;

Ok(())
}

/// Forcefully remove a Docker network, disconnecting any remaining
/// containers first. This ensures that stale Docker network endpoints
/// cannot prevent port bindings from being released.
Expand Down Expand Up @@ -993,6 +1075,71 @@ async fn force_remove_network(docker: &Docker, net_name: &str) -> Result<()> {
}
}

/// Ensure a stopped container is connected to the expected Docker network.
///
/// When a gateway is resumed after the container was killed (but not removed),
/// `ensure_network` destroys and recreates the network with a new ID. The
/// stopped container still holds a reference to the old network ID in its
/// config, so `docker start` would fail with a 404 "network not found" error.
///
/// This function disconnects the container from any networks that no longer
/// match the expected network name and connects it to the correct one.
async fn reconcile_container_network(
docker: &Docker,
container_name: &str,
expected_network: &str,
) -> Result<()> {
let info = docker
.inspect_container(container_name, None::<InspectContainerOptions>)
.await
.into_diagnostic()
.wrap_err("failed to inspect container for network reconciliation")?;

// Check the container's current network attachments via NetworkSettings.
let attached_networks: Vec<String> = info
.network_settings
.as_ref()
.and_then(|ns| ns.networks.as_ref())
.map(|nets| nets.keys().cloned().collect())
.unwrap_or_default();

// If the container is already attached to the expected network (by name),
// Docker will resolve the name to the current network ID on start.
// However, when the network was destroyed and recreated, the container's
// stored endpoint references the old ID. Disconnect and reconnect to
// pick up the new network ID.
for net_name in &attached_networks {
let _ = docker
.disconnect_network(
net_name,
NetworkDisconnectRequest {
container: container_name.to_string(),
force: Some(true),
},
)
.await;
}

// Connect to the (freshly created) expected network.
docker
.connect_network(
expected_network,
NetworkConnectRequest {
container: container_name.to_string(),
endpoint_config: Some(EndpointSettings::default()),
},
)
.await
.into_diagnostic()
.wrap_err("failed to connect container to gateway network")?;

tracing::debug!(
"Reconciled network for container {container_name}: disconnected from {attached_networks:?}, connected to {expected_network}"
);

Ok(())
}

fn is_not_found(err: &BollardError) -> bool {
matches!(
err,
Expand Down
Loading
Loading