Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .agents/skills/nemoclaw-cli/cli-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,13 +9,13 @@ Quick-reference for the `nemoclaw` command-line interface. For workflow guidance
| Flag | Description |
|------|-------------|
| `-v`, `--verbose` | Increase verbosity (`-v` = info, `-vv` = debug, `-vvv` = trace) |
| `-c`, `--gateway <NAME>` | Gateway to operate on. Also settable via `NEMOCLAW_CLUSTER` env var. Falls back to active gateway in `~/.config/nemoclaw/active_cluster`. |
| `-g`, `--gateway <NAME>` | Gateway to operate on. Also settable via `NEMOCLAW_CLUSTER` env var. Falls back to active gateway in `~/.config/nemoclaw/active_cluster`. |

## Environment Variables

| Variable | Description |
|----------|-------------|
| `NEMOCLAW_CLUSTER` | Override active cluster name (same as `--cluster`) |
| `NEMOCLAW_CLUSTER` | Override active gateway name (same as `--gateway`) |
| `NEMOCLAW_SANDBOX_POLICY` | Path to default sandbox policy YAML (fallback when `--policy` is not provided) |

---
Expand Down
2 changes: 1 addition & 1 deletion .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,7 @@
# basename (e.g. "nemoclaw-c").
#CLUSTER_NAME=nemoclaw-c

# Default cluster name used by `nemoclaw` commands in this repo when `--cluster`
# Default gateway name used by `nemoclaw` commands in this repo when `--gateway`
# is not provided. Usually matches CLUSTER_NAME.
#NEMOCLAW_CLUSTER=nemoclaw-c

Expand Down
10 changes: 5 additions & 5 deletions architecture/tui.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,17 +7,17 @@ The NemoClaw TUI is a terminal user interface for NemoClaw, inspired by [k9s](ht
The TUI is a subcommand of the NemoClaw CLI, so it inherits all your existing configuration — cluster selection, TLS settings, and verbosity flags all work the same way.

```bash
nemoclaw term # launch against the active cluster
nemoclaw term # launch against the active gateway
nav term # dev alias (builds from source)
nav term --cluster prod # target a specific cluster
nav term --gateway prod # target a specific gateway
NEMOCLAW_CLUSTER=prod nav term # same thing, via environment variable
```

Cluster resolution follows the same priority as the rest of the CLI:
Gateway resolution follows the same priority as the rest of the CLI:

1. `--cluster` flag (if provided)
1. `--gateway` flag (if provided)
2. `NEMOCLAW_CLUSTER` environment variable
3. Active cluster from `~/.config/nemoclaw/active_cluster`
3. Active gateway from `~/.config/nemoclaw/active_cluster`

No separate configuration files or authentication are needed.

Expand Down
64 changes: 42 additions & 22 deletions crates/navigator-bootstrap/src/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -123,14 +123,11 @@ pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result<Docker>
}

pub async fn ensure_network(docker: &Docker) -> Result<()> {
match docker
.inspect_network(NETWORK_NAME, None::<InspectNetworkOptions>)
.await
{
Ok(_) => return Ok(()),
Err(err) if is_not_found(&err) => {}
Err(err) => return Err(err).into_diagnostic(),
}
// Always remove and recreate the network to guarantee a clean state.
// Stale Docker networks (e.g., from a previous interrupted destroy or
// Docker Desktop restart) can leave broken routing that causes the
// container to fail with "no default routes found".
force_remove_network(docker).await?;

docker
.create_network(NetworkCreateRequest {
Expand DownExpand Up@@ -527,22 +524,45 @@ pub async fn destroy_cluster_resources(

// Remove the cluster image so the next deploy always pulls the latest
// version from the registry instead of reusing a stale local copy.
// Docker may briefly report the container as still running after a
// force-remove, so retry a few times on conflict (409) errors.
if let Some(ref image_id) = container_image {
tracing::debug!("Removing cluster image: {}", image_id);
let result = docker
.remove_image(
image_id,
Some(RemoveImageOptions {
force: true,
noprune: true,
..Default::default()
}),
None,
)
.await;
if let Err(err) = result
&& !is_not_found(&err)
{
let mut last_err = None;
for attempt in 0..5 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
match docker
.remove_image(
image_id,
Some(RemoveImageOptions {
force: true,
noprune: true,
..Default::default()
}),
None,
)
.await
{
Ok(_) => {
last_err = None;
break;
}
Err(err) if is_not_found(&err) => {
last_err = None;
break;
}
Err(err) if is_conflict(&err) => {
last_err = Some(err);
}
Err(err) => {
last_err = Some(err);
break;
}
}
}
if let Some(err) = last_err {
tracing::warn!("Failed to remove cluster image {}: {}", image_id, err);
}
}
Expand Down
42 changes: 23 additions & 19 deletions crates/navigator-bootstrap/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -243,7 +243,7 @@ where

// Ensure the image is available on the target Docker daemon
if remote_opts.is_some() {
log("[status] Pulling cluster image on remote host".to_string());
log("[status] Pulling gateway image".to_string());
let on_log_clone = Arc::clone(&on_log);
let progress_cb = move |msg: String| {
if let Ok(mut f) = on_log_clone.lock() {
Expand All@@ -253,14 +253,15 @@ where
image::pull_remote_image(&target_docker, &image_ref, progress_cb).await?;
} else {
// Local deployment: ensure image exists (pull if needed)
log("[status] Ensuring cluster image is available".to_string());
log("[status] Pulling gateway image".to_string());
ensure_image(&target_docker, &image_ref).await?;
}

// All subsequent operations use the target Docker (remote or local)
log("[status] Creating cluster network".to_string());
log("[status] Preparing gateway".to_string());
log("[progress] Creating gateway network".to_string());
ensure_network(&target_docker).await?;
log("[status] Preparing cluster volume".to_string());
log("[progress] Preparing gateway volume".to_string());
ensure_volume(&target_docker, &volume_name(&name)).await?;

// Compute extra TLS SANs for remote deployments so the gateway and k3s
Expand DownExpand Up@@ -297,7 +298,7 @@ where
(sans, gateway_host)
};

log("[status] Creating cluster container".to_string());
log("[progress] Creating gateway container".to_string());
ensure_container(
&target_docker,
&name,
Expand All@@ -308,26 +309,26 @@ where
kube_port,
)
.await?;
log("[status] Starting cluster container".to_string());
log("[status] Starting gateway".to_string());
start_container(&target_docker, &name).await?;

log("[status] Waiting for kubeconfig".to_string());
log("[progress] Waiting for kubeconfig".to_string());
let raw_kubeconfig = wait_for_kubeconfig(&target_docker, &name).await?;

// Rewrite kubeconfig based on deployment mode
let rewritten = remote_opts.as_ref().map_or_else(
|| rewrite_kubeconfig(&raw_kubeconfig, &name, kube_port),
|opts| rewrite_kubeconfig_remote(&raw_kubeconfig, &name, &opts.destination, kube_port),
);
log("[status] Writing kubeconfig".to_string());
log("[progress] Writing kubeconfig".to_string());
store_kubeconfig(&kubeconfig_path, &rewritten)?;
// Clean up stale k3s nodes left over from previous container instances that
// used the same persistent volume. Without this, pods remain scheduled on
// NotReady ghost nodes and the health check will time out.
log("[status] Cleaning stale nodes".to_string());
log("[progress] Cleaning stale nodes".to_string());
match clean_stale_nodes(&target_docker, &name).await {
Ok(0) => {}
Ok(n) => log(format!("[status] Removed {n} stale node(s)")),
Ok(n) => log(format!("[progress] Removed {n} stale node(s)")),
Err(err) => {
tracing::debug!("stale node cleanup failed (non-fatal): {err}");
}
Expand All@@ -344,19 +345,19 @@ where
// cluster, secrets are always newly generated and a restart is unnecessary.
// Restarting only when workload pre-existed avoids extra rollout latency.
let workload_existed_before_pki = navigator_workload_exists(&target_docker, &name).await?;
log("[status] Reconciling TLS certificates".to_string());
log("[progress] Reconciling TLS certificates".to_string());
let (pki_bundle, rotated) = reconcile_pki(&target_docker, &name, &extra_sans, &log).await?;

if rotated && workload_existed_before_pki {
// If a navigator workload is already running, it must be restarted so
// it picks up the new TLS secrets before we write CLI-side certs.
// A failed rollout is a hard error — CLI certs must not be persisted
// if the server cannot come up with the new PKI.
log("[status] PKI rotated — restarting navigator workload".to_string());
log("[progress] PKI rotated — restarting navigator workload".to_string());
restart_navigator_deployment(&target_docker, &name).await?;
}

log("[status] Storing CLI mTLS credentials".to_string());
log("[progress] Storing CLI mTLS credentials".to_string());
store_pki_bundle(&name, &pki_bundle)?;

// Push locally-built component images into the k3s containerd runtime.
Expand All@@ -373,7 +374,7 @@ where
.collect();
if !images.is_empty() {
log(format!(
"[status] Push mode: importing {} local image(s) into cluster",
"[progress] Importing {} local image(s) into gateway",
images.len()
));
let local_docker = Docker::connect_with_local_defaults().into_diagnostic()?;
Expand All@@ -393,12 +394,12 @@ where
)
.await?;

log("[status] Restarting navigator deployment to pick up imported images".to_string());
log("[progress] Restarting navigator deployment".to_string());
restart_navigator_deployment(&target_docker, &name).await?;
}
}

log("[status] Waiting for control plane health checks".to_string());
log("[status] Waiting for gateway".to_string());
{
// Create a short-lived closure that locks on each call rather than holding
// the MutexGuard across await points.
Expand All@@ -412,7 +413,7 @@ where
}

// Create and store cluster metadata
log("[status] Persisting cluster metadata".to_string());
log("[progress] Persisting gateway metadata".to_string());
let metadata = create_cluster_metadata_with_host(
&name,
remote_opts.as_ref(),
Expand DownExpand Up@@ -590,21 +591,24 @@ where
// Try to load existing secrets.
match load_existing_pki_bundle(docker, &cname, kubeconfig).await {
Ok(bundle) => {
log("[status] Reusing existing TLS certificates".to_string());
log("[progress] Reusing existing TLS certificates".to_string());
return Ok((bundle, false));
}
Err(reason) => {
log(format!(
"[status] Cannot reuse existing TLS secrets ({reason}) — generating new PKI"
"[progress] Cannot reuse existing TLS secrets ({reason}) — generating new PKI"
));
}
}

// 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 navigator namespace".to_string());
wait_for_namespace(docker, &cname, kubeconfig, "navigator").await?;
log("[progress] Generating TLS certificates".to_string());
let bundle = generate_pki(extra_sans)?;
log("[progress] Applying TLS secrets to gateway".to_string());
create_k8s_tls_secrets(docker, name, &bundle)
.await
.wrap_err("failed to apply new TLS secrets")?;
Expand Down
2 changes: 1 addition & 1 deletion crates/navigator-cli/src/bootstrap.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,7 +116,7 @@ pub fn confirm_bootstrap(override_value: Option<bool>) -> Result<bool> {

let confirmed = Confirm::new()
.with_prompt(format!(
"{} No cluster available to launch sandbox in. Create one now?",
"{} No gateway available to launch sandbox in. Create one now?",
"!".yellow()
))
.default(true)
Expand Down
Loading
Loading