refactor(vm): eliminate host-side kubectl dependency from boot pipeline

Remove all kubectl calls from the host-side boot sequence, eliminating
the need to forward port 6443 (kube-apiserver) outside the VM.

Changes:
- wait_for_gateway_service: TCP probe only (30051), no kubectl pod check
- bootstrap_gateway: cold boot writes TLS secret manifests via virtio-fs
  into k3s auto-deploy dir instead of kubectl apply
- bootstrap_gateway: warm boot skips namespace wait (TCP probe suffices)
- recover_stale_pods: removed entirely (gateway-init.sh already cleans
  containerd runtime/sandbox state, CNI state, and network namespaces)
- Kubeconfig copy moved to best-effort post-readiness (for debugging)
- Port 6443 removed from gvproxy port_map

Removed functions: recover_stale_pods, wait_for_namespace,
apply_tls_secrets, kubectl_apply.

Net: -362 lines, +147 lines. No kubectl binary required on host.
This commit is contained in:
Drew Newberry
2026-03-24 11:08:17 -07:00
parent 9e340ea755
commit 86d7b3d6b8
+145 -360
View File
@@ -161,13 +161,7 @@ impl VmConfig {
],
workdir: "/".to_string(),
port_map: vec![
// kube-apiserver — k3s runs the API server with host
// networking so it listens on VM:6443 directly. The
// host-side readiness check (`wait_for_gateway_service`)
// and `recover_stale_pods` both use kubectl against
// 127.0.0.1:6443 via the copied kubeconfig.
"6443:6443".to_string(),
// Navigator server — with bridge CNI the pod listens on
// OpenShell server — with bridge CNI the pod listens on
// 8080 inside its own network namespace (10.42.0.x), not
// on the VM's root namespace. The NodePort service
// (kube-proxy nftables) forwards VM:30051 → pod:8080.
@@ -955,88 +949,27 @@ pub fn launch(config: &VmConfig) -> Result<i32, VmError> {
);
}
// Wait for k3s kubeconfig to appear (virtio-fs makes it
// visible on the host). Only do this for the gateway preset
// (when exec_path is the default init script).
// Bootstrap the OpenShell control plane and wait for the
// service to be reachable. Only for the gateway preset.
if config.exec_path == "/srv/gateway-init.sh" {
let kubeconfig_src = config.rootfs.join("etc/rancher/k3s/k3s.yaml");
let kc_start = Instant::now();
eprintln!("Waiting for kubeconfig...");
// Aggressive polling initially (100ms) then back off to 1s.
// Total budget: ~90s (enough for k3s cold start).
let mut found = false;
let deadline = Instant::now() + std::time::Duration::from_secs(90);
let mut interval = std::time::Duration::from_millis(100);
while Instant::now() < deadline {
if kubeconfig_src.is_file()
&& std::fs::metadata(&kubeconfig_src)
.map(|m| m.len() > 0)
.unwrap_or(false)
{
found = true;
break;
}
std::thread::sleep(interval);
interval = (interval * 2).min(std::time::Duration::from_secs(1));
// Bootstrap stores host-side metadata and mTLS creds.
// With pre-baked rootfs (Path 1) this reads PKI directly
// from virtio-fs — no kubectl or port forwarding needed.
// Cold boot (Path 2) writes secret manifests into the
// k3s auto-deploy directory via virtio-fs.
if let Err(e) = bootstrap_gateway(&config.rootfs) {
eprintln!("Bootstrap failed: {e}");
eprintln!(" The VM is running but OpenShell may not be fully operational.");
}
if found {
eprintln!(
"Kubeconfig appeared [{:.1}s]",
kc_start.elapsed().as_secs_f64()
);
// Copy kubeconfig to ~/.kube/gateway.yaml, rewriting
// the server URL to point at the forwarded host port.
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let kube_dir = PathBuf::from(&home).join(".kube");
let _ = std::fs::create_dir_all(&kube_dir);
let dest = kube_dir.join("gateway.yaml");
match std::fs::read_to_string(&kubeconfig_src) {
Ok(contents) => {
if let Err(e) = std::fs::write(&dest, &contents) {
eprintln!(" failed to write kubeconfig: {e}");
} else {
eprintln!("Kubeconfig: {}", dest.display());
eprintln!(" export KUBECONFIG={}", dest.display());
}
}
Err(e) => {
eprintln!(" failed to read kubeconfig: {e}");
}
}
// Bootstrap the OpenShell control plane: generate PKI,
// create TLS secrets, and store cluster metadata so CLI
// clients and e2e tests can connect.
//
// If the rootfs has pre-baked PKI (from build-rootfs.sh),
// this skips the namespace wait and kubectl apply entirely.
if let Err(e) = bootstrap_gateway(&dest, &config.rootfs) {
eprintln!("Bootstrap failed: {e}");
eprintln!(
" The VM is running but OpenShell may not be fully operational."
);
}
} else {
eprintln!(" kubeconfig not found after 90s (k3s may still be starting)");
}
// On warm reboots (rootfs persists via virtio-fs), the k3s
// database may have stale pod records from the previous
// session. containerd v2 doesn't always recover these
// automatically. Force-delete any pods stuck in Unknown
// or failed state so the StatefulSet controller recreates
// them.
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let kubeconfig_dest = PathBuf::from(&home).join(".kube/gateway.yaml");
recover_stale_pods(&kubeconfig_dest);
// Wait for the gRPC service to be reachable before
// declaring "Ready". The openshell pod needs a few
// seconds after k3s starts to bind its port.
// Wait for the gRPC service to be reachable via TCP
// probe on host:30051. This confirms the full path
// (gvproxy → kube-proxy nftables → pod:8080) is working.
wait_for_gateway_service();
// Best-effort: copy kubeconfig for manual debugging.
// Not blocking — the boot pipeline doesn't need it.
copy_kubeconfig_best_effort(&config.rootfs);
}
eprintln!("Ready [{:.1}s total]", boot_start.elapsed().as_secs_f64());
@@ -1092,18 +1025,25 @@ const GATEWAY_PORT: u16 = 30051;
/// Bootstrap the OpenShell control plane after k3s is ready.
///
/// Three paths, fastest first:
/// All operations use the virtio-fs rootfs — no kubectl or API server
/// port forwarding required. This avoids exposing port 6443 outside the
/// VM.
///
/// 1. **Pre-baked PKI** (from `build-rootfs.sh`): reads PEM files directly
/// from the rootfs, stores creds + metadata on the host. No cluster
/// interaction at all. Completes in <50ms.
/// Three paths, in priority order:
///
/// 1. **Pre-baked rootfs** (from `build-rootfs.sh`): PKI files at
/// `rootfs/opt/openshell/pki/`. TLS secrets already exist in the k3s
/// database. Reads certs from the filesystem and stores metadata on the
/// host.
///
/// 2. **Warm boot**: host-side metadata + mTLS certs survive across VM
/// restarts. Waits for the openshell namespace, then returns.
/// restarts. Nothing to do — service readiness is confirmed by the TCP
/// probe in `wait_for_gateway_service()`.
///
/// 3. **Cold boot**: generates fresh PKI, waits for namespace, applies
/// secrets via kubectl, stores everything on the host.
fn bootstrap_gateway(kubeconfig: &Path, rootfs: &Path) -> Result<(), VmError> {
/// 3. **Cold boot**: generates fresh PKI and writes TLS secret manifests
/// into the k3s auto-deploy directory (`/var/lib/rancher/k3s/server/manifests/`)
/// via virtio-fs. k3s picks them up automatically.
fn bootstrap_gateway(rootfs: &Path) -> Result<(), VmError> {
let bootstrap_start = Instant::now();
// Build gateway metadata early — it only depends on knowing the port and
@@ -1167,15 +1107,10 @@ fn bootstrap_gateway(kubeconfig: &Path, rootfs: &Path) -> Result<(), VmError> {
// ── Path 2: Warm boot ──────────────────────────────────────────
//
// Host-side metadata + mTLS certs survive from a previous boot.
// Just wait for the namespace to confirm k3s is ready.
let kc = kubeconfig
.to_str()
.ok_or_else(|| VmError::InvalidPath(kubeconfig.display().to_string()))?;
// Service readiness is confirmed by the TCP probe in
// `wait_for_gateway_service()` — no kubectl needed here.
if is_warm_boot() {
eprintln!("Warm boot detected — reusing existing PKI and metadata.");
eprintln!("Waiting for openshell namespace...");
wait_for_namespace(kc)?;
eprintln!(
"Warm boot ready [{:.1}s]",
bootstrap_start.elapsed().as_secs_f64()
@@ -1187,6 +1122,10 @@ fn bootstrap_gateway(kubeconfig: &Path, rootfs: &Path) -> Result<(), VmError> {
}
// ── Path 3: Cold boot (no pre-baked state) ─────────────────────
//
// Generate fresh PKI and write TLS secret manifests into the k3s
// auto-deploy directory via virtio-fs. k3s watches this directory
// and applies any YAML files automatically.
eprintln!("Generating TLS certificates...");
let pki_bundle = openshell_bootstrap::pki::generate_pki(&[])
.map_err(|e| VmError::Bootstrap(format!("PKI generation failed: {e}")))?;
@@ -1194,13 +1133,12 @@ fn bootstrap_gateway(kubeconfig: &Path, rootfs: &Path) -> Result<(), VmError> {
openshell_bootstrap::store_gateway_metadata(GATEWAY_CLUSTER_NAME, &metadata)
.map_err(|e| VmError::Bootstrap(format!("failed to store cluster metadata: {e}")))?;
let ns_start = Instant::now();
eprintln!("Waiting for openshell namespace...");
wait_for_namespace(kc)?;
eprintln!("Namespace ready [{:.1}s]", ns_start.elapsed().as_secs_f64());
eprintln!("Creating TLS secrets...");
apply_tls_secrets(kc, &pki_bundle)?;
// Write TLS secrets as k3s auto-deploy manifests via virtio-fs.
// k3s watches /var/lib/rancher/k3s/server/manifests/ and applies
// any YAML files dropped there, eliminating the need for kubectl
// or API server port forwarding.
eprintln!("Writing TLS secret manifests via virtio-fs...");
write_tls_secret_manifests(rootfs, &pki_bundle)?;
openshell_bootstrap::mtls::store_pki_bundle(GATEWAY_CLUSTER_NAME, &pki_bundle)
.map_err(|e| VmError::Bootstrap(format!("failed to store mTLS credentials: {e}")))?;
@@ -1225,9 +1163,10 @@ fn bootstrap_gateway(kubeconfig: &Path, rootfs: &Path) -> Result<(), VmError> {
/// - Cluster metadata exists: `$XDG_CONFIG_HOME/openshell/gateways/gateway/metadata.json`
/// - mTLS certs exist: `$XDG_CONFIG_HOME/openshell/gateways/gateway/mtls/{ca.crt,tls.crt,tls.key}`
///
/// When true, the host-side bootstrap (PKI generation, kubectl apply, metadata
/// storage) can be skipped because the virtio-fs rootfs persists k3s state
/// (TLS certs, kine/sqlite, containerd images, helm releases) across VM restarts.
/// When true, the host-side bootstrap (PKI generation, secret manifest writing,
/// metadata storage) can be skipped because the virtio-fs rootfs persists k3s
/// state (TLS certs, kine/sqlite, containerd images, helm releases) across VM
/// restarts.
fn is_warm_boot() -> bool {
let Ok(home) = std::env::var("HOME") else {
return false;
@@ -1267,50 +1206,24 @@ fn is_warm_boot() -> bool {
/// across boots so the native snapshotter doesn't re-extract image layers.
/// Runtime task state is cleaned by `gateway-init.sh` on each boot.
///
/// We poll kubectl for `Ready=True`, then verify with a host-side TCP
/// probe to `127.0.0.1:30051` to confirm the full gvproxy->VM->pod
/// path works. gvproxy accepts TCP connections even when nothing listens
/// in the guest, but those connections reset immediately. A connection
/// that stays open (server waiting for TLS `ClientHello`) proves the pod
/// is genuinely serving.
/// Wait for the OpenShell gRPC service to be reachable from the host.
///
/// Polls `host_tcp_probe()` on `127.0.0.1:30051` with 1s intervals.
/// The probe confirms the full networking path: gvproxy → kube-proxy
/// nftables → pod:8080. A successful probe means the pod is running,
/// the NodePort service is routing, and the server is accepting
/// connections. No kubectl or API server access required.
fn wait_for_gateway_service() {
let start = Instant::now();
let timeout = std::time::Duration::from_secs(90);
let poll_interval = std::time::Duration::from_secs(1);
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let kubeconfig = PathBuf::from(&home).join(".kube/gateway.yaml");
let kc = kubeconfig.to_string_lossy();
eprintln!("Waiting for gateway service...");
loop {
// Check if the pod is Ready.
let is_ready = std::process::Command::new("kubectl")
.args(["--kubeconfig", &kc])
.args([
"-n",
"openshell",
"get",
"pod",
"openshell-0",
"-o",
"jsonpath={.status.conditions[?(@.type==\"Ready\")].status}",
])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.is_some_and(|s| s == "True");
if is_ready {
// Pod reports Ready — verify with a host-side TCP probe to
// confirm the full gvproxy -> VM -> pod path works.
if host_tcp_probe() {
eprintln!("Service healthy [{:.1}s]", start.elapsed().as_secs_f64());
return;
}
eprintln!(" pod Ready but host TCP probe failed, retrying...");
if host_tcp_probe() {
eprintln!("Service healthy [{:.1}s]", start.elapsed().as_secs_f64());
return;
}
if start.elapsed() >= timeout {
@@ -1325,95 +1238,6 @@ fn wait_for_gateway_service() {
}
}
/// Force-delete pods stuck in `Unknown` or failed states (safety net).
///
/// On warm reboots (virtio-fs persists rootfs across VM restarts), the
/// k3s database retains pod records from the previous session. Containerd
/// runtime task state is cleaned but metadata (meta.db) is preserved to
/// avoid re-extracting image layers. This function is a safety net for
/// edge cases where reconciliation fails — it force-deletes pods in
/// `Unknown` or `Failed` state so controllers can recreate them.
fn recover_stale_pods(kubeconfig: &Path) {
let kc = kubeconfig.to_string_lossy();
// Wait briefly for the API server to be responsive.
let deadline = Instant::now() + std::time::Duration::from_secs(30);
let mut interval = std::time::Duration::from_millis(500);
loop {
if let Ok(output) = std::process::Command::new("kubectl")
.args(["--kubeconfig", &kc])
.args(["get", "nodes", "-o", "name"])
.output()
{
if output.status.success() {
break;
}
}
if Instant::now() >= deadline {
eprintln!(" API server not ready after 30s, skipping pod recovery");
return;
}
std::thread::sleep(interval);
interval = (interval * 2).min(std::time::Duration::from_secs(2));
}
// Get all pods in a parseable format: namespace/name status
let output = std::process::Command::new("kubectl")
.args(["--kubeconfig", &kc])
.args([
"get", "pods", "-A",
"-o", "jsonpath={range .items[*]}{.metadata.namespace}/{.metadata.name} {.status.phase}\\n{end}",
])
.output();
let Ok(output) = output else { return };
if !output.status.success() {
return;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut stale_count = 0u32;
for line in stdout.lines() {
let parts: Vec<&str> = line.trim().split_whitespace().collect();
if parts.len() != 2 {
continue;
}
let (ns_name, phase) = (parts[0], parts[1]);
// Delete pods in Unknown or Failed state — they can't recover
// from stale containerd sandbox state.
if phase == "Unknown" || phase == "Failed" {
let ns_and_name: Vec<&str> = ns_name.splitn(2, '/').collect();
if ns_and_name.len() != 2 {
continue;
}
let (ns, name) = (ns_and_name[0], ns_and_name[1]);
let result = std::process::Command::new("kubectl")
.args(["--kubeconfig", &kc])
.args([
"-n",
ns,
"delete",
"pod",
name,
"--force",
"--grace-period=0",
])
.output();
if let Ok(r) = result {
if r.status.success() {
stale_count += 1;
}
}
}
}
if stale_count > 0 {
eprintln!("Recovered {stale_count} stale pod(s)");
}
}
/// Probe `127.0.0.1:30051` from the host to verify the full
/// gvproxy → VM → pod path is working.
///
@@ -1451,146 +1275,107 @@ fn host_tcp_probe() -> bool {
}
}
/// Poll kubectl until the `openshell` namespace exists.
/// Write TLS secret manifests into the k3s auto-deploy directory via virtio-fs.
///
/// Uses exponential backoff (500ms → 3s) to minimize latency when the
/// namespace appears quickly while avoiding kubectl spam.
fn wait_for_namespace(kubeconfig: &str) -> Result<(), VmError> {
let start = Instant::now();
let timeout = std::time::Duration::from_secs(180);
let mut interval = std::time::Duration::from_millis(500);
let mut attempts = 0u32;
loop {
let output = std::process::Command::new("kubectl")
.args(["--kubeconfig", kubeconfig])
.args(["get", "namespace", "openshell", "-o", "name"])
.output();
if let Ok(output) = output
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("openshell") {
return Ok(());
}
}
if start.elapsed() >= timeout {
return Err(VmError::Bootstrap(
"timed out waiting for openshell namespace (180s). \
Check console.log for k3s errors."
.to_string(),
));
}
attempts += 1;
if attempts.is_multiple_of(10) {
eprintln!(
" still waiting for openshell namespace ({:.0}s elapsed)",
start.elapsed().as_secs_f64()
);
}
std::thread::sleep(interval);
interval = (interval * 2).min(std::time::Duration::from_secs(3));
}
}
/// Apply the three TLS K8s secrets required by the OpenShell server.
///
/// Uses `kubectl apply -f -` on the host, piping JSON manifests via stdin.
fn apply_tls_secrets(
kubeconfig: &str,
/// k3s watches `/var/lib/rancher/k3s/server/manifests/` and automatically
/// applies any YAML files placed there. This avoids the need for kubectl
/// or API server port forwarding from the host.
fn write_tls_secret_manifests(
rootfs: &Path,
bundle: &openshell_bootstrap::pki::PkiBundle,
) -> Result<(), VmError> {
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
let secrets = [
// 1. openshell-server-tls (kubernetes.io/tls)
serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": openshell_bootstrap::constants::SERVER_TLS_SECRET_NAME,
"namespace": "openshell"
},
"type": "kubernetes.io/tls",
"data": {
"tls.crt": STANDARD.encode(&bundle.server_cert_pem),
"tls.key": STANDARD.encode(&bundle.server_key_pem)
}
}),
// 2. openshell-server-client-ca (Opaque)
serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": openshell_bootstrap::constants::SERVER_CLIENT_CA_SECRET_NAME,
"namespace": "openshell"
},
"type": "Opaque",
"data": {
"ca.crt": STANDARD.encode(&bundle.ca_cert_pem)
}
}),
// 3. openshell-client-tls (Opaque) — shared by CLI and sandbox pods
serde_json::json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": openshell_bootstrap::constants::CLIENT_TLS_SECRET_NAME,
"namespace": "openshell"
},
"type": "Opaque",
"data": {
"tls.crt": STANDARD.encode(&bundle.client_cert_pem),
"tls.key": STANDARD.encode(&bundle.client_key_pem),
"ca.crt": STANDARD.encode(&bundle.ca_cert_pem)
}
}),
];
let manifests_dir = rootfs.join("var/lib/rancher/k3s/server/manifests");
std::fs::create_dir_all(&manifests_dir)
.map_err(|e| VmError::Bootstrap(format!("failed to create manifests dir: {e}")))?;
for secret in &secrets {
let name = secret["metadata"]["name"].as_str().unwrap_or("unknown");
kubectl_apply(kubeconfig, &secret.to_string())
.map_err(|e| VmError::Bootstrap(format!("failed to create secret {name}: {e}")))?;
eprintln!(" secret/{name} created");
}
let server_tls_name = openshell_bootstrap::constants::SERVER_TLS_SECRET_NAME;
let client_ca_name = openshell_bootstrap::constants::SERVER_CLIENT_CA_SECRET_NAME;
let client_tls_name = openshell_bootstrap::constants::CLIENT_TLS_SECRET_NAME;
// Combine all three secrets into a single multi-document YAML file.
// k3s applies the entire file atomically.
let manifest = format!(
r#"---
apiVersion: v1
kind: Namespace
metadata:
name: openshell
---
apiVersion: v1
kind: Secret
metadata:
name: {server_tls_name}
namespace: openshell
type: kubernetes.io/tls
data:
tls.crt: {server_crt}
tls.key: {server_key}
---
apiVersion: v1
kind: Secret
metadata:
name: {client_ca_name}
namespace: openshell
type: Opaque
data:
ca.crt: {ca_crt}
---
apiVersion: v1
kind: Secret
metadata:
name: {client_tls_name}
namespace: openshell
type: Opaque
data:
tls.crt: {client_crt}
tls.key: {client_key}
ca.crt: {ca_crt}
"#,
server_crt = STANDARD.encode(&bundle.server_cert_pem),
server_key = STANDARD.encode(&bundle.server_key_pem),
ca_crt = STANDARD.encode(&bundle.ca_cert_pem),
client_crt = STANDARD.encode(&bundle.client_cert_pem),
client_key = STANDARD.encode(&bundle.client_key_pem),
);
let dest = manifests_dir.join("openshell-tls-secrets.yaml");
std::fs::write(&dest, manifest)
.map_err(|e| VmError::Bootstrap(format!("failed to write TLS manifest: {e}")))?;
eprintln!(" TLS secret manifests written to {}", dest.display());
Ok(())
}
/// Run `kubectl apply -f -` with the given manifest piped via stdin.
fn kubectl_apply(kubeconfig: &str, manifest: &str) -> Result<(), String> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new("kubectl")
.args(["--kubeconfig", kubeconfig, "apply", "-f", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to spawn kubectl: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(manifest.as_bytes())
.map_err(|e| format!("failed to write manifest to kubectl stdin: {e}"))?;
/// Best-effort: copy the k3s kubeconfig to `~/.kube/gateway.yaml` for
/// manual debugging. Not required for the boot pipeline — runs after
/// the service is already confirmed healthy.
fn copy_kubeconfig_best_effort(rootfs: &Path) {
let kubeconfig_src = rootfs.join("etc/rancher/k3s/k3s.yaml");
if !kubeconfig_src.is_file() {
return;
}
let output = child
.wait_with_output()
.map_err(|e| format!("failed to wait for kubectl: {e}"))?;
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let kube_dir = PathBuf::from(&home).join(".kube");
let _ = std::fs::create_dir_all(&kube_dir);
let dest = kube_dir.join("gateway.yaml");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("kubectl apply failed: {stderr}"));
match std::fs::read_to_string(&kubeconfig_src) {
Ok(contents) => {
if let Err(e) = std::fs::write(&dest, &contents) {
eprintln!(" failed to write kubeconfig: {e}");
} else {
eprintln!("Kubeconfig: {}", dest.display());
eprintln!(" export KUBECONFIG={}", dest.display());
}
}
Err(e) => {
eprintln!(" failed to read kubeconfig: {e}");
}
}
Ok(())
}
static CHILD_PID: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);