Skip to content

Commit 4a1c1dd

Browse files
committed
macOS: add vfkit backend for ephemeral and persistent VMs
macOS has no KVM/QEMU, so this adds vfkit as the VM backend. Ephemeral VMs use a custom nbdkit EROFS plugin that dynamically generates rootfs, ESP, and GPT from the container overlay via NBD. Persistent VMs use EFI boot. The vfkit/ module mirrors the libvirt/ directory structure, and CLI options match Linux where applicable. Plugin distribution method is TBD. Build and run on macOS: cargo build --release codesign -fs - target/release/bcvk Tested on macOS (Apple Silicon) with rootful and rootless podman machine. Assisted-by: Claude Code (Opus 4.6) Signed-off-by: Shion Tanaka <shtanaka@redhat.com>
1 parent c3c9b14 commit 4a1c1dd

18 files changed

Lines changed: 2595 additions & 366 deletions

Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/kit/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ libsystemd = "0.7"
6060

6161
# macOS-only dependencies (vfkit backend)
6262
[target.'cfg(target_os = "macos")'.dependencies]
63+
rustix = { version = "1", features = ["process"] }
6364
zstd = "0.13"
6465

6566
[dev-dependencies]

crates/kit/src/ephemeral_macos.rs

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,28 +137,55 @@ fn cmd_rm_all(force: bool) -> Result<()> {
137137

138138
for vm in &vms {
139139
if vm.is_alive() {
140-
if let Err(e) = Command::new("kill")
141-
.args([&vm.pid.to_string()])
142-
.stdout(Stdio::null())
143-
.stderr(Stdio::null())
144-
.status()
145-
{
140+
if let Err(e) = rustix::process::kill_process(
141+
rustix::process::Pid::from_raw(vm.pid as i32).unwrap(),
142+
rustix::process::Signal::TERM,
143+
) {
146144
tracing::warn!("failed to kill VM process {}: {}", vm.pid, e);
147145
}
148146
if vm.gvproxy_pid > 0 {
149-
if let Err(e) = Command::new("kill")
150-
.args([&vm.gvproxy_pid.to_string()])
151-
.stdout(Stdio::null())
152-
.stderr(Stdio::null())
153-
.status()
154-
{
147+
if let Err(e) = rustix::process::kill_process(
148+
rustix::process::Pid::from_raw(vm.gvproxy_pid as i32).unwrap(),
149+
rustix::process::Signal::TERM,
150+
) {
155151
tracing::warn!("failed to kill gvproxy {}: {}", vm.gvproxy_pid, e);
156152
}
157153
}
158154
}
155+
if let Some(ref container) = vm.nbd_container {
156+
crate::nbdkit_macos::stop_nbdkit_container(container);
157+
}
159158
EphemeralVmMetadata::remove(&vm.name);
160159
println!("Removed {}", vm.name);
161160
}
161+
162+
// Sweep orphaned resources inside podman machine
163+
if let Ok(machine) = run_ephemeral_macos::detect_machine_name() {
164+
// Remove orphaned nbdkit containers
165+
let _ = Command::new("podman")
166+
.args([
167+
"machine",
168+
"ssh",
169+
&machine,
170+
"--",
171+
"podman",
172+
"rm",
173+
"-f",
174+
"--filter",
175+
"name=bcvk-nbd-",
176+
])
177+
.stdout(Stdio::null())
178+
.stderr(Stdio::null())
179+
.status();
180+
// Unmount any remaining container image overlays
181+
let _ = Command::new("podman")
182+
.args([
183+
"machine", "ssh", &machine, "--", "podman", "image", "umount", "--all",
184+
])
185+
.stdout(Stdio::null())
186+
.stderr(Stdio::null())
187+
.status();
188+
}
162189
Ok(())
163190
}
164191

@@ -170,7 +197,8 @@ fn cmd_ssh(name: &str, args: &[String]) -> Result<()> {
170197
}
171198

172199
// Try to set up SSH port forwarding via VM-specific gvproxy socket
173-
let svc_sock = format!("/private/tmp/bcvk/{}-gvproxy-svc.sock", name);
200+
let base = run_ephemeral_macos::ephemeral_base_dir();
201+
let svc_sock = format!("{}/{}-gvproxy-svc.sock", base.display(), name);
174202
if std::path::Path::new(&svc_sock).exists() {
175203
if let Err(e) =
176204
run_ephemeral_macos::expose_ssh_port(&svc_sock, "192.168.127.2", vm.ssh_port)

crates/kit/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ pub mod kernel;
1313

1414
// macOS-only modules (vfkit backend)
1515
#[cfg(target_os = "macos")]
16+
pub mod nbdkit_macos;
17+
#[cfg(target_os = "macos")]
1618
pub mod run_ephemeral_macos;
1719

1820
#[cfg(target_os = "macos")]

crates/kit/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ mod varlink_ipc;
6565
#[cfg(target_os = "macos")]
6666
mod ephemeral_macos;
6767
#[cfg(target_os = "macos")]
68+
mod nbdkit_macos;
69+
#[cfg(target_os = "macos")]
6870
mod run_ephemeral_macos;
6971
#[cfg(target_os = "macos")]
7072
mod vfkit;

crates/kit/src/nbdkit_macos.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
//! nbdkit EROFS plugin management for macOS ephemeral VMs.
2+
3+
use color_eyre::{
4+
eyre::{bail, Context},
5+
Result,
6+
};
7+
use std::process::{Command, Stdio};
8+
use std::time::Duration;
9+
use tracing::info;
10+
11+
use crate::run_ephemeral_macos::detect_machine_name;
12+
13+
/// Path to the nbdkit EROFS plugin shared library inside podman machine.
14+
const NBDKIT_EROFS_PLUGIN_PATH: &str = "/var/tmp/bcvk/libnbdkit_erofs_plugin.so";
15+
16+
/// Get the merged overlay path from podman image mount.
17+
pub(crate) fn get_merged_path(machine: &str, rootful: bool, image: &str) -> Result<String> {
18+
let output = if rootful {
19+
Command::new("podman")
20+
.args([
21+
"machine", "ssh", machine, "--", "podman", "image", "mount", image,
22+
])
23+
.output()
24+
.context("podman image mount")?
25+
} else {
26+
Command::new("podman")
27+
.args([
28+
"machine", "ssh", machine, "--", "podman", "unshare", "podman", "image", "mount",
29+
image,
30+
])
31+
.output()
32+
.context("podman image mount")?
33+
};
34+
if !output.status.success() {
35+
let stderr = String::from_utf8_lossy(&output.stderr);
36+
bail!("podman image mount failed: {}", stderr.trim());
37+
}
38+
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
39+
}
40+
41+
/// Start nbdkit with the erofs plugin for dynamic EROFS + ESP + GPT generation.
42+
pub(crate) fn start_nbdkit_erofs_plugin(
43+
machine: &str,
44+
merged_path: &str,
45+
cmdline: &str,
46+
ssh_pubkey: &str,
47+
nbd_port: u16,
48+
vm_name: &str,
49+
) -> Result<String> {
50+
let container_name = format!("bcvk-nbd-{}", vm_name);
51+
52+
let _ = Command::new("podman")
53+
.args([
54+
"machine",
55+
"ssh",
56+
machine,
57+
"--",
58+
"podman",
59+
"rm",
60+
"-f",
61+
&container_name,
62+
])
63+
.stdout(Stdio::null())
64+
.stderr(Stdio::null())
65+
.status();
66+
67+
fn shell_escape(s: &str) -> String {
68+
format!("'{}'", s.replace('\'', "'\\''"))
69+
}
70+
71+
let cmdline_esc = shell_escape(&format!("cmdline={}", cmdline));
72+
let dir_esc = shell_escape(&format!("dir={}", merged_path));
73+
74+
let mut ssh_param = String::new();
75+
if !ssh_pubkey.is_empty() {
76+
ssh_param = format!(" {}", shell_escape(&format!("ssh_pubkey={}", ssh_pubkey)));
77+
}
78+
79+
let podman_cmd = format!(
80+
"podman run -d --name {name} --security-opt label=disable \
81+
-p {port}:10809 \
82+
-v {merged}:{merged}:ro \
83+
-v {plugin}:/plugin.so:ro \
84+
-v /usr/bin/nbdkit:/usr/bin/nbdkit:ro \
85+
-v /usr/lib64/nbdkit:/usr/lib64/nbdkit:ro \
86+
quay.io/fedora/fedora:latest \
87+
nbdkit -f -p 10809 -r /plugin.so \
88+
{dir} {cmdline}{ssh}",
89+
name = container_name,
90+
port = nbd_port,
91+
merged = merged_path,
92+
plugin = NBDKIT_EROFS_PLUGIN_PATH,
93+
dir = dir_esc,
94+
cmdline = cmdline_esc,
95+
ssh = ssh_param,
96+
);
97+
98+
let output = Command::new("podman")
99+
.args(["machine", "ssh", machine, "--", &podman_cmd])
100+
.output()
101+
.context("failed to start nbdkit erofs plugin")?;
102+
103+
if !output.status.success() {
104+
let stderr = String::from_utf8_lossy(&output.stderr);
105+
bail!("failed to start nbdkit erofs plugin: {}", stderr.trim());
106+
}
107+
108+
info!("waiting for nbdkit on port {}...", nbd_port);
109+
let deadline = std::time::Instant::now() + Duration::from_secs(30);
110+
loop {
111+
if let Ok(mut stream) = std::net::TcpStream::connect_timeout(
112+
&std::net::SocketAddr::from(([127, 0, 0, 1], nbd_port)),
113+
Duration::from_millis(500),
114+
) {
115+
use std::io::Read;
116+
stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
117+
let mut buf = [0u8; 8];
118+
if stream.read_exact(&mut buf).is_ok() && &buf == b"NBDMAGIC" {
119+
break;
120+
}
121+
}
122+
if std::time::Instant::now() > deadline {
123+
let _ = Command::new("podman")
124+
.args([
125+
"machine",
126+
"ssh",
127+
machine,
128+
"--",
129+
"podman",
130+
"rm",
131+
"-f",
132+
&container_name,
133+
])
134+
.stdout(Stdio::null())
135+
.stderr(Stdio::null())
136+
.status();
137+
bail!(
138+
"nbdkit erofs plugin did not become ready on port {}",
139+
nbd_port
140+
);
141+
}
142+
std::thread::sleep(Duration::from_millis(500));
143+
}
144+
145+
Ok(container_name)
146+
}
147+
148+
/// Find an available TCP port for NBD in range 10800-10900.
149+
pub fn find_available_nbd_port() -> u16 {
150+
use rand::Rng;
151+
let mut rng = rand::rng();
152+
const PORT_RANGE_START: u16 = 10800;
153+
const PORT_RANGE_END: u16 = 10900;
154+
for _ in 0..100 {
155+
let port = rng.random_range(PORT_RANGE_START..PORT_RANGE_END);
156+
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
157+
return port;
158+
}
159+
}
160+
for port in PORT_RANGE_START..PORT_RANGE_END {
161+
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
162+
return port;
163+
}
164+
}
165+
PORT_RANGE_START
166+
}
167+
168+
/// Stop and remove an nbdkit container (best-effort).
169+
pub fn stop_nbdkit_container(container_name: &str) {
170+
if let Ok(machine) = detect_machine_name() {
171+
let _ = Command::new("podman")
172+
.args([
173+
"machine",
174+
"ssh",
175+
&machine,
176+
"--",
177+
"podman",
178+
"rm",
179+
"-f",
180+
container_name,
181+
])
182+
.stdout(Stdio::null())
183+
.stderr(Stdio::null())
184+
.status();
185+
}
186+
}

0 commit comments

Comments
 (0)