Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
129 changes: 127 additions & 2 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 @@ -482,6 +483,17 @@ 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(());
}

Expand Down Expand Up @@ -532,6 +544,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 @@ -919,6 +937,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 @@ -956,6 +1016,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
142 changes: 118 additions & 24 deletions crates/openshell-bootstrap/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ use miette::{IntoDiagnostic, Result};
use std::sync::{Arc, Mutex};

use crate::constants::{
CLIENT_TLS_SECRET_NAME, SERVER_CLIENT_CA_SECRET_NAME, SERVER_TLS_SECRET_NAME, network_name,
volume_name,
CLIENT_TLS_SECRET_NAME, SERVER_CLIENT_CA_SECRET_NAME, SERVER_TLS_SECRET_NAME,
SSH_HANDSHAKE_SECRET_NAME, network_name, volume_name,
};
use crate::docker::{
check_existing_gateway, check_port_conflicts, destroy_gateway_resources, ensure_container,
ensure_image, ensure_network, ensure_volume, start_container, stop_container,
check_existing_gateway, check_port_conflicts, cleanup_gateway_container,
destroy_gateway_resources, ensure_container, ensure_image, ensure_network, ensure_volume,
start_container, stop_container,
};
use crate::metadata::{
create_gateway_metadata, create_gateway_metadata_with_host, local_gateway_host,
Expand Down Expand Up @@ -288,19 +289,22 @@ where
(preflight.docker, None)
};

// If an existing gateway is found, either tear it down (when recreate is
// requested) or bail out so the caller can prompt the user / reuse it.
// If an existing gateway is found, decide how to proceed:
// - recreate: destroy everything and start fresh
// - otherwise: auto-resume from existing state (the ensure_* calls are
// idempotent and will reuse the volume, create a container if needed,
// and start it)
let mut resume = false;
if let Some(existing) = check_existing_gateway(&target_docker, &name).await? {
if recreate {
log("[status] Removing existing gateway".to_string());
destroy_gateway_resources(&target_docker, &name).await?;
} else if existing.container_running {
log("[status] Gateway is already running".to_string());
resume = true;
} else {
return Err(miette::miette!(
"Gateway '{name}' already exists (container_running={}).\n\
Use --recreate to destroy and redeploy, or destroy it first with:\n\n \
openshell gateway destroy --name {name}",
existing.container_running,
));
log("[status] Resuming gateway from existing state".to_string());
resume = true;
}
}

Expand Down Expand Up @@ -455,6 +459,11 @@ where

store_pki_bundle(&name, &pki_bundle)?;

// Reconcile SSH handshake secret: reuse existing K8s secret if present,
// generate and persist a new one otherwise. This secret is stored in etcd
// (on the persistent volume) so it survives container restarts.
reconcile_ssh_handshake_secret(&target_docker, &name, &log).await?;

// Push locally-built component images into the k3s containerd runtime.
// This is the "push" path for local development — images are exported from
// the local Docker daemon and streamed into the cluster's containerd so
Expand Down Expand Up @@ -524,15 +533,30 @@ where
docker: target_docker,
}),
Err(deploy_err) => {
// Automatically clean up Docker resources (volume, container, network,
// image) so the environment is left in a retryable state.
tracing::info!("deploy failed, cleaning up gateway resources for '{name}'");
if let Err(cleanup_err) = destroy_gateway_resources(&target_docker, &name).await {
tracing::warn!(
"automatic cleanup after failed deploy also failed: {cleanup_err}. \
Manual cleanup may be required: \
openshell gateway destroy --name {name}"
if resume {
// When resuming, preserve the volume so the user can retry.
// Only clean up the container and network that we may have created.
tracing::info!(
"resume failed, cleaning up container for '{name}' (preserving volume)"
);
if let Err(cleanup_err) = cleanup_gateway_container(&target_docker, &name).await {
tracing::warn!(
"automatic cleanup after failed resume also failed: {cleanup_err}. \
Manual cleanup may be required: \
openshell gateway destroy --name {name}"
);
}
} else {
// Automatically clean up Docker resources (volume, container, network,
// image) so the environment is left in a retryable state.
tracing::info!("deploy failed, cleaning up gateway resources for '{name}'");
if let Err(cleanup_err) = destroy_gateway_resources(&target_docker, &name).await {
tracing::warn!(
"automatic cleanup after failed deploy also failed: {cleanup_err}. \
Manual cleanup may be required: \
openshell gateway destroy --name {name}"
);
}
}
Err(deploy_err)
}
Expand Down Expand Up @@ -809,6 +833,14 @@ where
let cname = container_name(name);
let kubeconfig = constants::KUBECONFIG_PATH;

// Wait for the k3s API server and openshell namespace before attempting
// to read secrets. Without this, kubectl fails transiently on resume
// (k3s hasn't booted yet), the code assumes secrets are gone, and
// regenerates PKI unnecessarily — triggering a server rollout restart
// and TLS errors for in-flight connections.
log("[progress] Waiting for openshell namespace".to_string());
wait_for_namespace(docker, &cname, kubeconfig, "openshell").await?;

// Try to load existing secrets.
match load_existing_pki_bundle(docker, &cname, kubeconfig).await {
Ok(bundle) => {
Expand All @@ -823,10 +855,6 @@ where
}

// Generate fresh PKI and apply to cluster.
// Namespace may still be creating on first bootstrap, so wait here only
// when rotation is actually needed.
log("[progress] Waiting for openshell namespace".to_string());
wait_for_namespace(docker, &cname, kubeconfig, "openshell").await?;
log("[progress] Generating TLS certificates".to_string());
let bundle = generate_pki(extra_sans)?;
log("[progress] Applying TLS secrets to gateway".to_string());
Expand All @@ -837,6 +865,72 @@ where
Ok((bundle, true))
}

/// Reconcile the SSH handshake HMAC secret as a Kubernetes Secret.
///
/// If the secret already exists in the cluster, this is a no-op. Otherwise a
/// fresh 32-byte hex secret is generated and applied. Because the secret lives
/// in etcd (backed by the persistent Docker volume), it survives container
/// restarts without regeneration — existing sandbox SSH sessions remain valid.
async fn reconcile_ssh_handshake_secret<F>(docker: &Docker, name: &str, log: &F) -> Result<()>
where
F: Fn(String) + Sync,
{
use miette::WrapErr;

let cname = container_name(name);
let kubeconfig = constants::KUBECONFIG_PATH;

// Check if the secret already exists.
let (output, exit_code) = exec_capture_with_exit(
docker,
&cname,
vec![
"sh".to_string(),
"-c".to_string(),
format!(
"KUBECONFIG={kubeconfig} kubectl -n openshell get secret {SSH_HANDSHAKE_SECRET_NAME} -o jsonpath='{{.data.secret}}' 2>/dev/null"
),
],
)
.await?;

if exit_code == 0 && !output.trim().is_empty() {
tracing::debug!(
"existing SSH handshake secret found ({} bytes encoded)",
output.trim().len()
);
log("[progress] Reusing existing SSH handshake secret".to_string());
return Ok(());
}

// Generate a new 32-byte hex secret and create the K8s secret.
log("[progress] Generating SSH handshake secret".to_string());
let (output, exit_code) = exec_capture_with_exit(
docker,
&cname,
vec![
"sh".to_string(),
"-c".to_string(),
format!(
"SECRET=$(head -c 32 /dev/urandom | od -A n -t x1 | tr -d ' \\n') && \
KUBECONFIG={kubeconfig} kubectl -n openshell create secret generic {SSH_HANDSHAKE_SECRET_NAME} \
--from-literal=secret=$SECRET --dry-run=client -o yaml | \
KUBECONFIG={kubeconfig} kubectl apply -f -"
),
],
)
.await?;

if exit_code != 0 {
return Err(miette::miette!(
"failed to create SSH handshake secret (exit {exit_code}): {output}"
))
.wrap_err("failed to apply SSH handshake secret");
}

Ok(())
}

/// Load existing TLS secrets from the cluster and reconstruct a [`PkiBundle`].
///
/// Returns an error string describing why secrets couldn't be loaded (for logging).
Expand Down
Loading
Loading