Skip to content

Commit 73aa16c

Browse files
committed
refactor(vm): extract gateway into standalone binary
Move the gateway VM launching out of `nemoclaw gateway` into its own `gateway` binary built from the navigator-vm crate. The nemoclaw CLI no longer links against libkrun or requires macOS hypervisor codesigning. Add scripts/bin/gateway wrapper (build + codesign + exec) and clean up scripts/bin/nemoclaw to remove navigator-vm artifacts.
1 parent 2c53e75 commit 73aa16c

File tree

7 files changed

+188
-138
lines changed

7 files changed

+188
-138
lines changed

Cargo.lock

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/navigator-cli/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ navigator-core = { path = "../navigator-core" }
2020
navigator-policy = { path = "../navigator-policy" }
2121
navigator-providers = { path = "../navigator-providers" }
2222
navigator-tui = { path = "../navigator-tui" }
23-
navigator-vm = { path = "../navigator-vm" }
2423
serde = { workspace = true }
2524
serde_json = { workspace = true }
2625
serde_yaml = { workspace = true }

crates/navigator-cli/src/main.rs

Lines changed: 1 addition & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@ use clap_complete::env::CompleteEnv;
99
use miette::Result;
1010
use owo_colors::OwoColorize;
1111
use std::io::Write;
12-
use std::path::PathBuf;
1312

1413
use navigator_bootstrap::{
15-
load_active_cluster, load_cluster_metadata, load_last_sandbox, paths, save_last_sandbox,
14+
load_active_cluster, load_cluster_metadata, load_last_sandbox, save_last_sandbox,
1615
};
1716
use navigator_cli::completers;
1817
use navigator_cli::run;
@@ -146,54 +145,6 @@ enum Commands {
146145
/// Launch the NemoClaw interactive TUI.
147146
Term,
148147

149-
/// Boot a libkrun microVM.
150-
///
151-
/// By default, starts a k3s Kubernetes cluster inside the VM with the
152-
/// API server on port 6443. Use `--exec` to run a custom process instead.
153-
Gateway {
154-
/// Path to the rootfs directory (aarch64 Linux).
155-
/// Defaults to `~/.local/share/nemoclaw/gateway/rootfs`.
156-
#[arg(long, value_hint = ValueHint::DirPath)]
157-
rootfs: Option<PathBuf>,
158-
159-
/// Executable path inside the VM. When set, runs this instead of
160-
/// the default k3s server.
161-
#[arg(long)]
162-
exec: Option<String>,
163-
164-
/// Arguments to the executable (requires `--exec`).
165-
#[arg(long, num_args = 1..)]
166-
args: Vec<String>,
167-
168-
/// Environment variables in `KEY=VALUE` form (requires `--exec`).
169-
#[arg(long, num_args = 1..)]
170-
env: Vec<String>,
171-
172-
/// Working directory inside the VM.
173-
#[arg(long, default_value = "/")]
174-
workdir: String,
175-
176-
/// Port mappings (`host_port:guest_port`).
177-
#[arg(long, short, num_args = 1..)]
178-
port: Vec<String>,
179-
180-
/// Number of virtual CPUs (default: 4 for gateway, 2 for --exec).
181-
#[arg(long)]
182-
vcpus: Option<u8>,
183-
184-
/// RAM in MiB (default: 8192 for gateway, 2048 for --exec).
185-
#[arg(long)]
186-
mem: Option<u32>,
187-
188-
/// libkrun log level (0=Off .. 5=Trace).
189-
#[arg(long, default_value_t = 1)]
190-
krun_log_level: u32,
191-
192-
/// Networking backend: "gvproxy" (default), "tsi", or "none".
193-
#[arg(long, default_value = "gvproxy")]
194-
net: String,
195-
},
196-
197148
/// Generate shell completions.
198149
#[command(after_long_help = COMPLETIONS_HELP)]
199150
Completions {
@@ -1340,76 +1291,6 @@ async fn main() -> Result<()> {
13401291
let channel = navigator_cli::tls::build_channel(&ctx.endpoint, &tls).await?;
13411292
navigator_tui::run(channel, &ctx.name, &ctx.endpoint).await?;
13421293
}
1343-
Some(Commands::Gateway {
1344-
rootfs,
1345-
exec,
1346-
args,
1347-
env,
1348-
workdir,
1349-
port,
1350-
vcpus,
1351-
mem,
1352-
krun_log_level,
1353-
net,
1354-
}) => {
1355-
let net_backend = match net.as_str() {
1356-
"tsi" => navigator_vm::NetBackend::Tsi,
1357-
"none" => navigator_vm::NetBackend::None,
1358-
"gvproxy" => navigator_vm::NetBackend::Gvproxy {
1359-
binary: PathBuf::from(
1360-
// Try to find gvproxy
1361-
[
1362-
"/opt/podman/bin/gvproxy",
1363-
"/opt/homebrew/bin/gvproxy",
1364-
"/usr/local/bin/gvproxy",
1365-
]
1366-
.iter()
1367-
.find(|p| std::path::Path::new(p).exists())
1368-
.unwrap_or(&"/opt/podman/bin/gvproxy"),
1369-
),
1370-
},
1371-
other => {
1372-
return Err(miette::miette!(
1373-
"unknown --net backend: {other} (expected: gvproxy, tsi, none)"
1374-
));
1375-
}
1376-
};
1377-
1378-
let rootfs = rootfs.map_or_else(paths::default_rootfs_dir, Ok)?;
1379-
let mut config = if let Some(exec_path) = exec {
1380-
navigator_vm::VmConfig {
1381-
rootfs,
1382-
vcpus: vcpus.unwrap_or(2),
1383-
mem_mib: mem.unwrap_or(2048),
1384-
exec_path,
1385-
args,
1386-
env,
1387-
workdir,
1388-
port_map: port,
1389-
log_level: krun_log_level,
1390-
console_output: None,
1391-
net: net_backend.clone(),
1392-
}
1393-
} else {
1394-
let mut c = navigator_vm::VmConfig::gateway(rootfs);
1395-
if !port.is_empty() {
1396-
c.port_map = port;
1397-
}
1398-
if let Some(v) = vcpus {
1399-
c.vcpus = v;
1400-
}
1401-
if let Some(m) = mem {
1402-
c.mem_mib = m;
1403-
}
1404-
c.net = net_backend;
1405-
c
1406-
};
1407-
config.log_level = krun_log_level;
1408-
let code = navigator_vm::launch(&config).map_err(|e| miette::miette!("{e}"))?;
1409-
if code != 0 {
1410-
std::process::exit(code);
1411-
}
1412-
}
14131294
Some(Commands::Completions { shell }) => {
14141295
let exe = std::env::current_exe()
14151296
.map_err(|e| miette::miette!("failed to find current executable: {e}"))?;

crates/navigator-vm/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,20 @@ description = "MicroVM runtime using libkrun for hardware-isolated execution"
1414
name = "navigator_vm"
1515
path = "src/lib.rs"
1616

17+
[[bin]]
18+
name = "gateway"
19+
path = "src/main.rs"
20+
1721
[dependencies]
1822
base64 = "0.22"
23+
clap = { workspace = true }
1924
libc = "0.2"
2025
miette = { workspace = true }
2126
navigator-bootstrap = { path = "../navigator-bootstrap" }
2227
serde_json = "1"
2328
thiserror = { workspace = true }
29+
tracing = { workspace = true }
30+
tracing-subscriber = { workspace = true }
2431

2532
[lints]
2633
workspace = true

crates/navigator-vm/src/main.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Standalone gateway binary.
5+
//!
6+
//! Boots a libkrun microVM running the NemoClaw control plane (k3s +
7+
//! navigator-server). By default it uses the pre-built rootfs at
8+
//! `~/.local/share/nemoclaw/gateway/rootfs`.
9+
//!
10+
//! # Codesigning (macOS)
11+
//!
12+
//! This binary must be codesigned with the `com.apple.security.hypervisor`
13+
//! entitlement. See `entitlements.plist` in this crate.
14+
//!
15+
//! ```sh
16+
//! codesign --entitlements crates/navigator-vm/entitlements.plist --force -s - target/debug/gateway
17+
//! ```
18+
19+
use std::path::PathBuf;
20+
21+
use clap::{Parser, ValueHint};
22+
23+
/// Boot the NemoClaw gateway microVM.
24+
///
25+
/// Starts a libkrun microVM running a k3s Kubernetes cluster with the
26+
/// NemoClaw control plane. Use `--exec` to run a custom process instead.
27+
#[derive(Parser)]
28+
#[command(name = "gateway", version)]
29+
struct Cli {
30+
/// Path to the rootfs directory (aarch64 Linux).
31+
/// Defaults to `~/.local/share/nemoclaw/gateway/rootfs`.
32+
#[arg(long, value_hint = ValueHint::DirPath)]
33+
rootfs: Option<PathBuf>,
34+
35+
/// Executable path inside the VM. When set, runs this instead of
36+
/// the default k3s server.
37+
#[arg(long)]
38+
exec: Option<String>,
39+
40+
/// Arguments to the executable (requires `--exec`).
41+
#[arg(long, num_args = 1..)]
42+
args: Vec<String>,
43+
44+
/// Environment variables in `KEY=VALUE` form (requires `--exec`).
45+
#[arg(long, num_args = 1..)]
46+
env: Vec<String>,
47+
48+
/// Working directory inside the VM.
49+
#[arg(long, default_value = "/")]
50+
workdir: String,
51+
52+
/// Port mappings (`host_port:guest_port`).
53+
#[arg(long, short, num_args = 1..)]
54+
port: Vec<String>,
55+
56+
/// Number of virtual CPUs (default: 4 for gateway, 2 for --exec).
57+
#[arg(long)]
58+
vcpus: Option<u8>,
59+
60+
/// RAM in MiB (default: 8192 for gateway, 2048 for --exec).
61+
#[arg(long)]
62+
mem: Option<u32>,
63+
64+
/// libkrun log level (0=Off .. 5=Trace).
65+
#[arg(long, default_value_t = 1)]
66+
krun_log_level: u32,
67+
68+
/// Networking backend: "gvproxy" (default), "tsi", or "none".
69+
#[arg(long, default_value = "gvproxy")]
70+
net: String,
71+
}
72+
73+
fn main() {
74+
tracing_subscriber::fmt::init();
75+
76+
let cli = Cli::parse();
77+
78+
let code = match run(cli) {
79+
Ok(code) => code,
80+
Err(e) => {
81+
eprintln!("Error: {e}");
82+
1
83+
}
84+
};
85+
86+
if code != 0 {
87+
std::process::exit(code);
88+
}
89+
}
90+
91+
fn run(cli: Cli) -> Result<i32, Box<dyn std::error::Error>> {
92+
let net_backend = match cli.net.as_str() {
93+
"tsi" => navigator_vm::NetBackend::Tsi,
94+
"none" => navigator_vm::NetBackend::None,
95+
"gvproxy" => navigator_vm::NetBackend::Gvproxy {
96+
binary: PathBuf::from(
97+
[
98+
"/opt/podman/bin/gvproxy",
99+
"/opt/homebrew/bin/gvproxy",
100+
"/usr/local/bin/gvproxy",
101+
]
102+
.iter()
103+
.find(|p| std::path::Path::new(p).exists())
104+
.unwrap_or(&"/opt/podman/bin/gvproxy"),
105+
),
106+
},
107+
other => {
108+
return Err(
109+
format!("unknown --net backend: {other} (expected: gvproxy, tsi, none)").into(),
110+
);
111+
}
112+
};
113+
114+
let rootfs = match cli.rootfs {
115+
Some(p) => p,
116+
None => navigator_bootstrap::paths::default_rootfs_dir()?,
117+
};
118+
119+
let mut config = if let Some(exec_path) = cli.exec {
120+
navigator_vm::VmConfig {
121+
rootfs,
122+
vcpus: cli.vcpus.unwrap_or(2),
123+
mem_mib: cli.mem.unwrap_or(2048),
124+
exec_path,
125+
args: cli.args,
126+
env: cli.env,
127+
workdir: cli.workdir,
128+
port_map: cli.port,
129+
log_level: cli.krun_log_level,
130+
console_output: None,
131+
net: net_backend.clone(),
132+
}
133+
} else {
134+
let mut c = navigator_vm::VmConfig::gateway(rootfs);
135+
if !cli.port.is_empty() {
136+
c.port_map = cli.port;
137+
}
138+
if let Some(v) = cli.vcpus {
139+
c.vcpus = v;
140+
}
141+
if let Some(m) = cli.mem {
142+
c.mem_mib = m;
143+
}
144+
c.net = net_backend;
145+
c
146+
};
147+
config.log_level = cli.krun_log_level;
148+
149+
Ok(navigator_vm::launch(&config)?)
150+
}

scripts/bin/gateway

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env bash
2+
set -e
3+
4+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5+
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
6+
BINARY="$PROJECT_ROOT/target/debug/gateway"
7+
8+
cargo build --package navigator-vm --bin gateway --quiet
9+
10+
# On macOS, codesign with the hypervisor entitlement so libkrun can use
11+
# Apple's Hypervisor.framework. Re-sign after every build.
12+
ENTITLEMENTS="$PROJECT_ROOT/crates/navigator-vm/entitlements.plist"
13+
if [[ "$(uname)" == "Darwin" ]] && [[ -f "$ENTITLEMENTS" ]]; then
14+
codesign --entitlements "$ENTITLEMENTS" --force -s - "$BINARY" 2>/dev/null
15+
fi
16+
17+
# Ensure libkrunfw is discoverable by libkrun's dlopen on macOS.
18+
# dyld only reads DYLD_FALLBACK_LIBRARY_PATH at process startup, so we
19+
# set it here before exec.
20+
if [[ "$(uname)" == "Darwin" ]]; then
21+
HOMEBREW_LIB="$(brew --prefix 2>/dev/null || echo /opt/homebrew)/lib"
22+
export DYLD_FALLBACK_LIBRARY_PATH="${HOMEBREW_LIB}${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}"
23+
fi
24+
25+
exec "$BINARY" "$@"

0 commit comments

Comments
 (0)