Skip to content

Commit 9a603e5

Browse files
authored
feat(cli): detect port conflicts before gateway start, add sandbox delete --all, and improve spinner spacing (#225)
1 parent 3efa03d commit 9a603e5

9 files changed

Lines changed: 498 additions & 41 deletions

File tree

.agents/skills/debug-navigator-cluster/SKILL.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ If `/readyz` fails, k3s is still starting or has crashed. Check container logs (
132132

133133
If pods are in `CrashLoopBackOff`, `ImagePullBackOff`, or `Pending`, investigate those pods specifically.
134134

135+
Also check for node pressure conditions that cause the kubelet to evict pods and reject scheduling:
136+
137+
```bash
138+
# Check node conditions (DiskPressure, MemoryPressure, PIDPressure)
139+
docker exec openshell-cluster-<name> sh -lc 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes -o jsonpath="{range .items[*]}{.metadata.name}{range .status.conditions[*]} {.type}={.status}{end}{\"\n\"}{end}"'
140+
141+
# Check disk usage inside the container
142+
docker exec openshell-cluster-<name> df -h /
143+
144+
# Check memory usage
145+
docker exec openshell-cluster-<name> free -h
146+
```
147+
148+
If any pressure condition is `True`, pods will be evicted and new ones rejected. The bootstrap now detects `HEALTHCHECK_NODE_PRESSURE` markers from the health-check script and aborts early with a clear diagnosis. To fix: free disk/memory on the host, then recreate the gateway.
149+
135150
### Step 4: Check OpenShell Server StatefulSet
136151

137152
The OpenShell server is deployed via a HelmChart CR as a StatefulSet with persistent storage. Check its status:
@@ -305,6 +320,8 @@ If DNS is broken, all image pulls from the distribution registry will fail, as w
305320
| Port conflict | Another service on 6443 or the configured gateway host port (default 8080) | Stop conflicting service or use `--port` on `openshell gateway start` to pick a different host port |
306321
| gRPC connect refused to `127.0.0.1:443` in CI | Docker daemon is remote (`DOCKER_HOST=tcp://...`) but metadata still points to loopback | Verify metadata endpoint host matches `DOCKER_HOST` and includes non-loopback host |
307322
| DNS failures inside container | Entrypoint DNS detection failed | Check `/etc/rancher/k3s/resolv.conf` and container startup logs |
323+
| Node DiskPressure / MemoryPressure / PIDPressure | Insufficient disk, memory, or PIDs on host | Free disk (`docker system prune -a --volumes`), increase memory, or expand host resources. Bootstrap auto-detects via `HEALTHCHECK_NODE_PRESSURE` marker |
324+
| Pods evicted with "The node had condition: [DiskPressure]" | Host disk full, kubelet evicting pods | Free disk space on host, then `openshell gateway destroy <name> && openshell gateway start` |
308325
| `metrics-server` errors in logs | Normal k3s noise, not the root cause | These errors are benign — look for the actual failing health check component |
309326
| Stale NotReady nodes from previous deploys | Volume reused across container recreations | The deploy flow now auto-cleans stale nodes; if it still fails, manually delete NotReady nodes (see Step 3) or choose "Recreate" when prompted |
310327
| gRPC `UNIMPLEMENTED` for newer RPCs in push mode | Helm values still point at older pulled images instead of the pushed refs | Verify rendered `navigator-helmchart.yaml` uses the expected push refs (`server`, `sandbox`, `pki-job`) and not `:latest` |
@@ -363,6 +380,12 @@ run docker exec "${CONTAINER}" sh -lc "${KCFG} kubectl get --raw='/readyz'" 2>&1
363380
echo "=== Nodes ==="
364381
run docker exec "${CONTAINER}" sh -lc "${KCFG} kubectl get nodes -o wide" 2>&1
365382

383+
echo "=== Node Conditions ==="
384+
run docker exec "${CONTAINER}" sh -lc "${KCFG} kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{range .status.conditions[*]} {.type}={.status}{end}{\"\n\"}{end}'" 2>&1
385+
386+
echo "=== Disk Usage ==="
387+
run docker exec "${CONTAINER}" df -h / 2>&1
388+
366389
echo "=== All Pods ==="
367390
run docker exec "${CONTAINER}" sh -lc "${KCFG} kubectl get pods -A -o wide" 2>&1
368391

crates/navigator-bootstrap/src/docker.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ use bollard::models::{
1515
};
1616
use bollard::query_parameters::{
1717
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, InspectNetworkOptions,
18-
RemoveContainerOptions, RemoveImageOptions, RemoveVolumeOptions, StartContainerOptions,
18+
ListContainersOptionsBuilder, RemoveContainerOptions, RemoveImageOptions, RemoveVolumeOptions,
19+
StartContainerOptions,
1920
};
2021
use futures::StreamExt;
2122
use miette::{IntoDiagnostic, Result, WrapErr};
@@ -479,6 +480,77 @@ pub async fn ensure_container(
479480
Ok(())
480481
}
481482

483+
/// Information about a container that is holding a port we need.
484+
#[derive(Debug, Clone)]
485+
pub struct PortConflict {
486+
/// Name of the container holding the port (without leading `/`).
487+
pub container_name: String,
488+
/// The host port that conflicts.
489+
pub host_port: u16,
490+
}
491+
492+
/// Check whether any *other* running container already binds the host ports
493+
/// that the gateway needs. Returns a list of conflicts (empty if none).
494+
///
495+
/// Docker silently fails to attach networking when a port is already bound,
496+
/// leaving the new container with only a loopback interface. Detecting this
497+
/// up-front lets us give a clear error instead of a cryptic "no default route"
498+
/// failure 30 seconds later.
499+
pub async fn check_port_conflicts(
500+
docker: &Docker,
501+
name: &str,
502+
gateway_port: u16,
503+
kube_port: Option<u16>,
504+
) -> Result<Vec<PortConflict>> {
505+
let our_container = container_name(name);
506+
let needed_ports: Vec<u16> = std::iter::once(gateway_port).chain(kube_port).collect();
507+
508+
let containers = docker
509+
.list_containers(Some(
510+
ListContainersOptionsBuilder::new()
511+
// Only running containers can hold port bindings.
512+
.all(false)
513+
.build(),
514+
))
515+
.await
516+
.into_diagnostic()
517+
.wrap_err("failed to list containers for port conflict check")?;
518+
519+
let mut conflicts = Vec::new();
520+
for container in &containers {
521+
// Skip our own container (it may already exist from a previous run).
522+
let names = container.names.as_deref().unwrap_or_default();
523+
let is_ours = names
524+
.iter()
525+
.any(|n| n.trim_start_matches('/') == our_container);
526+
if is_ours {
527+
continue;
528+
}
529+
530+
let ports = container.ports.as_deref().unwrap_or_default();
531+
for port in ports {
532+
if let Some(public) = port.public_port {
533+
if needed_ports.contains(&public) {
534+
let cname = names
535+
.first()
536+
.map(|n| n.trim_start_matches('/').to_string())
537+
.unwrap_or_else(|| {
538+
container
539+
.id
540+
.clone()
541+
.unwrap_or_else(|| "<unknown>".to_string())
542+
});
543+
conflicts.push(PortConflict {
544+
container_name: cname,
545+
host_port: public,
546+
});
547+
}
548+
}
549+
}
550+
}
551+
Ok(conflicts)
552+
}
553+
482554
pub async fn start_container(docker: &Docker, name: &str) -> Result<()> {
483555
let container_name = container_name(name);
484556

crates/navigator-bootstrap/src/errors.rs

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,12 @@ const FAILURE_PATTERNS: &[FailurePattern] = &[
133133
match_mode: MatchMode::Any,
134134
diagnose: diagnose_oom_killed,
135135
},
136+
// Node resource pressure (DiskPressure, MemoryPressure, PIDPressure)
137+
FailurePattern {
138+
matchers: &["HEALTHCHECK_NODE_PRESSURE"],
139+
match_mode: MatchMode::Any,
140+
diagnose: diagnose_node_pressure,
141+
},
136142
// TLS/certificate issues
137143
FailurePattern {
138144
matchers: &[
@@ -178,10 +184,18 @@ fn diagnose_corrupted_state(gateway_name: &str) -> GatewayFailureDiagnosis {
178184
fn diagnose_no_default_route(_gateway_name: &str) -> GatewayFailureDiagnosis {
179185
GatewayFailureDiagnosis {
180186
summary: "Docker networking issue".to_string(),
181-
explanation: "The gateway container has no network route. This is usually caused by \
182-
stale Docker networks or Docker Desktop networking issues."
187+
explanation: "The gateway container has no network route. This can happen when \
188+
another container is already bound to the same host port (Docker silently \
189+
skips network attachment), or due to stale Docker networks."
183190
.to_string(),
184191
recovery_steps: vec![
192+
RecoveryStep::with_command(
193+
"Check for containers using the same port",
194+
"docker ps --format '{{.Names}}\\t{{.Ports}}'",
195+
),
196+
RecoveryStep::new(
197+
"Stop any container holding the gateway port (default 8080), then retry",
198+
),
185199
RecoveryStep::with_command("Prune unused Docker networks", "docker network prune -f"),
186200
RecoveryStep::new("Restart Docker Desktop (if on Mac/Windows)"),
187201
RecoveryStep::new("Then retry: openshell gateway start"),
@@ -237,11 +251,22 @@ fn diagnose_k3s_dns_proxy_failure(gateway_name: &str) -> GatewayFailureDiagnosis
237251
GatewayFailureDiagnosis {
238252
summary: "Cluster DNS resolution failed".to_string(),
239253
explanation: "The gateway cluster started but its internal DNS proxy cannot resolve \
240-
external hostnames. This is typically caused by stale Docker networking state \
241-
or Docker Desktop DNS configuration issues."
254+
external hostnames. Docker's embedded DNS inside the container cannot reach \
255+
an upstream resolver. This is typically caused by Docker not being configured \
256+
with the host's DNS servers, stale Docker networking state, or (on Desktop) \
257+
DNS configuration issues."
242258
.to_string(),
243259
recovery_steps: vec![
244-
RecoveryStep::new("Restart Docker Desktop"),
260+
RecoveryStep::with_command(
261+
"Check your host's DNS servers",
262+
"resolvectl status | grep 'DNS Servers' -A2",
263+
),
264+
RecoveryStep::with_command(
265+
"Configure Docker to use those DNS servers \
266+
(add to /etc/docker/daemon.json, then restart Docker)",
267+
"echo '{\"dns\": [\"<your-dns-server-ip>\"]}' | sudo tee /etc/docker/daemon.json \
268+
&& sudo systemctl restart docker",
269+
),
245270
RecoveryStep::with_command("Prune Docker networks", "docker network prune -f"),
246271
RecoveryStep::with_command(
247272
"Destroy and recreate the gateway",
@@ -289,6 +314,34 @@ fn diagnose_oom_killed(_gateway_name: &str) -> GatewayFailureDiagnosis {
289314
}
290315
}
291316

317+
fn diagnose_node_pressure(gateway_name: &str) -> GatewayFailureDiagnosis {
318+
GatewayFailureDiagnosis {
319+
summary: "Node under resource pressure".to_string(),
320+
explanation: "The cluster node is reporting a resource pressure condition \
321+
(DiskPressure, MemoryPressure, or PIDPressure). When a node is under \
322+
pressure the kubelet evicts running pods and rejects new pod scheduling, \
323+
so the gateway will never become healthy until the pressure is resolved."
324+
.to_string(),
325+
recovery_steps: vec![
326+
RecoveryStep::with_command("Check available disk space on the host", "df -h /"),
327+
RecoveryStep::with_command(
328+
"Free disk space by pruning unused Docker resources",
329+
"docker system prune -a --volumes",
330+
),
331+
RecoveryStep::with_command("Check available memory on the host", "free -h"),
332+
RecoveryStep::new(
333+
"Increase Docker resource allocation \
334+
(Docker Desktop → Settings → Resources), or free resources on the host",
335+
),
336+
RecoveryStep::with_command(
337+
"Destroy and recreate the gateway after freeing resources",
338+
format!("openshell gateway destroy {gateway_name} && openshell gateway start"),
339+
),
340+
],
341+
retryable: false,
342+
}
343+
}
344+
292345
fn diagnose_certificate_issue(gateway_name: &str) -> GatewayFailureDiagnosis {
293346
GatewayFailureDiagnosis {
294347
summary: "TLS certificate issue".to_string(),
@@ -437,4 +490,59 @@ mod tests {
437490
let diagnosis = diagnose_failure("test", "Try again later", None);
438491
assert!(diagnosis.is_none());
439492
}
493+
494+
#[test]
495+
fn test_diagnose_node_pressure_disk() {
496+
let diagnosis = diagnose_failure(
497+
"test",
498+
"HEALTHCHECK_NODE_PRESSURE: DiskPressure\n\
499+
The cluster node is under resource pressure.",
500+
None,
501+
);
502+
assert!(diagnosis.is_some());
503+
let d = diagnosis.unwrap();
504+
assert!(
505+
d.summary.contains("pressure"),
506+
"expected pressure diagnosis, got: {}",
507+
d.summary
508+
);
509+
assert!(!d.retryable);
510+
}
511+
512+
#[test]
513+
fn test_diagnose_node_pressure_from_container_logs() {
514+
let diagnosis = diagnose_failure(
515+
"test",
516+
"gateway health check reported unhealthy",
517+
Some("HEALTHCHECK_NODE_PRESSURE: MemoryPressure"),
518+
);
519+
assert!(diagnosis.is_some());
520+
let d = diagnosis.unwrap();
521+
assert!(
522+
d.summary.contains("pressure"),
523+
"expected pressure diagnosis, got: {}",
524+
d.summary
525+
);
526+
}
527+
528+
#[test]
529+
fn test_diagnose_dns_failure_from_namespace_timeout() {
530+
// When wait_for_namespace detects DNS failure, the error message itself
531+
// (not container logs) contains the DNS markers. The diagnose_failure
532+
// function must match these from the error_message parameter alone,
533+
// since container_logs may be None in this path.
534+
let diagnosis = diagnose_failure(
535+
"test",
536+
"K8s namespace not ready\n\nCaused by:\n dial tcp: lookup registry: Try again\n DNS resolution is failing inside the gateway container.",
537+
None,
538+
);
539+
assert!(diagnosis.is_some());
540+
let d = diagnosis.unwrap();
541+
assert!(
542+
d.summary.contains("DNS"),
543+
"expected DNS diagnosis, got: {}",
544+
d.summary
545+
);
546+
assert!(d.retryable);
547+
}
440548
}

0 commit comments

Comments
 (0)