Skip to content

Commit c3c9b14

Browse files
committed
feat(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 direct kernel boot with SquashFS, persistent VMs use EFI boot. The vfkit/ module mirrors the libvirt/ directory structure, and CLI options match Linux where applicable. 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 (Claude Opus 4.6) Signed-off-by: Shion Tanaka <shtanaka@redhat.com>
1 parent 80ef0e0 commit c3c9b14

16 files changed

Lines changed: 2370 additions & 10 deletions

Cargo.lock

Lines changed: 41 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ zlink = "0.4"
5858
futures-util = "0.3"
5959
libsystemd = "0.7"
6060

61+
# macOS-only dependencies (vfkit backend)
62+
[target.'cfg(target_os = "macos")'.dependencies]
63+
zstd = "0.13"
64+
6165
[dev-dependencies]
6266
similar-asserts = "1.5"
6367

crates/kit/src/ephemeral_macos.rs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
//! Ephemeral VM management commands for macOS (vfkit backend).
2+
3+
use std::io::Write;
4+
use std::process::{Command, Stdio};
5+
6+
use clap::Subcommand;
7+
use color_eyre::eyre::bail;
8+
use color_eyre::Result;
9+
10+
use crate::run_ephemeral_macos::{self, EphemeralVmMetadata};
11+
12+
/// Options for `ephemeral run-ssh`, combining run options with optional SSH arguments.
13+
#[derive(Debug, clap::Parser)]
14+
pub struct RunSshOpts {
15+
#[command(flatten)]
16+
pub run_opts: run_ephemeral_macos::RunEphemeralOpts,
17+
18+
/// SSH command to execute (optional, defaults to interactive shell)
19+
#[arg(trailing_var_arg = true)]
20+
pub ssh_args: Vec<String>,
21+
}
22+
23+
#[derive(Debug, Subcommand)]
24+
pub enum EphemeralCommands {
25+
/// Run bootc containers as ephemeral VMs
26+
#[clap(name = "run")]
27+
Run(run_ephemeral_macos::RunEphemeralOpts),
28+
29+
/// Run ephemeral VM and SSH into it
30+
#[clap(name = "run-ssh")]
31+
RunSsh(RunSshOpts),
32+
33+
/// Connect to a running ephemeral VM via SSH
34+
#[clap(name = "ssh")]
35+
Ssh {
36+
/// VM name
37+
name: String,
38+
39+
/// Additional SSH arguments (e.g. -v, -L, commands to execute)
40+
#[clap(allow_hyphen_values = true)]
41+
args: Vec<String>,
42+
},
43+
44+
/// List ephemeral VM containers
45+
#[clap(name = "ps")]
46+
Ps {
47+
/// Output as JSON
48+
#[clap(long)]
49+
json: bool,
50+
},
51+
52+
/// Remove all ephemeral VM containers
53+
#[clap(name = "rm-all")]
54+
RmAll {
55+
/// Force removal without confirmation
56+
#[clap(short, long)]
57+
force: bool,
58+
},
59+
}
60+
61+
impl EphemeralCommands {
62+
/// Execute the ephemeral subcommand.
63+
pub fn run(self) -> Result<()> {
64+
match self {
65+
EphemeralCommands::Run(opts) => run_ephemeral_macos::run(opts),
66+
EphemeralCommands::RunSsh(mut opts) => {
67+
opts.run_opts.ssh_keygen = true;
68+
if !opts.ssh_args.is_empty() {
69+
let combined = shlex::try_join(opts.ssh_args.iter().map(|s| s.as_str()))
70+
.map_err(|e| color_eyre::eyre::eyre!("failed to escape SSH args: {}", e))?;
71+
opts.run_opts.execute.push(combined);
72+
}
73+
run_ephemeral_macos::run(opts.run_opts)
74+
}
75+
EphemeralCommands::Ssh { name, args } => cmd_ssh(&name, &args),
76+
EphemeralCommands::Ps { json } => cmd_ps(json),
77+
EphemeralCommands::RmAll { force } => cmd_rm_all(force),
78+
}
79+
}
80+
}
81+
82+
fn cmd_ps(json: bool) -> Result<()> {
83+
let vms = EphemeralVmMetadata::list_all()?;
84+
for vm in &vms {
85+
if !vm.is_alive() {
86+
EphemeralVmMetadata::remove(&vm.name);
87+
}
88+
}
89+
let live: Vec<_> = vms.into_iter().filter(|vm| vm.is_alive()).collect();
90+
91+
if json {
92+
println!("{}", serde_json::to_string_pretty(&live)?);
93+
return Ok(());
94+
}
95+
96+
if live.is_empty() {
97+
println!("No running ephemeral VMs.");
98+
return Ok(());
99+
}
100+
101+
println!("{:<24} {:<50} SSH", "NAME", "IMAGE");
102+
for vm in &live {
103+
println!(
104+
"{:<24} {:<50} ssh -p {} -i {} root@localhost",
105+
vm.name, vm.image, vm.ssh_port, vm.ssh_key
106+
);
107+
}
108+
Ok(())
109+
}
110+
111+
fn cmd_rm_all(force: bool) -> Result<()> {
112+
let vms = EphemeralVmMetadata::list_all()?;
113+
if vms.is_empty() {
114+
println!("No ephemeral VMs found.");
115+
return Ok(());
116+
}
117+
118+
if !force {
119+
println!("Found {} ephemeral VM(s):", vms.len());
120+
for vm in &vms {
121+
println!(
122+
" {} ({})",
123+
vm.name,
124+
if vm.is_alive() { "running" } else { "stopped" }
125+
);
126+
}
127+
print!("Remove all ephemeral VMs? [y/N]: ");
128+
std::io::stdout().flush()?;
129+
let mut input = String::new();
130+
std::io::stdin().read_line(&mut input)?;
131+
let input = input.trim().to_lowercase();
132+
if input != "y" && input != "yes" {
133+
println!("Aborted.");
134+
return Ok(());
135+
}
136+
}
137+
138+
for vm in &vms {
139+
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+
{
146+
tracing::warn!("failed to kill VM process {}: {}", vm.pid, e);
147+
}
148+
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+
{
155+
tracing::warn!("failed to kill gvproxy {}: {}", vm.gvproxy_pid, e);
156+
}
157+
}
158+
}
159+
EphemeralVmMetadata::remove(&vm.name);
160+
println!("Removed {}", vm.name);
161+
}
162+
Ok(())
163+
}
164+
165+
fn cmd_ssh(name: &str, args: &[String]) -> Result<()> {
166+
let vm = EphemeralVmMetadata::load(name)?;
167+
if !vm.is_alive() {
168+
EphemeralVmMetadata::remove(name);
169+
bail!("VM '{}' is not running", name);
170+
}
171+
172+
// Try to set up SSH port forwarding via VM-specific gvproxy socket
173+
let svc_sock = format!("/private/tmp/bcvk/{}-gvproxy-svc.sock", name);
174+
if std::path::Path::new(&svc_sock).exists() {
175+
if let Err(e) =
176+
run_ephemeral_macos::expose_ssh_port(&svc_sock, "192.168.127.2", vm.ssh_port)
177+
{
178+
tracing::debug!("SSH port forward re-expose: {}", e);
179+
}
180+
}
181+
182+
let key_path = std::path::Path::new(&vm.ssh_key);
183+
if args.is_empty() {
184+
run_ephemeral_macos::run_ssh_interactive(vm.ssh_port, key_path, "root")?;
185+
} else {
186+
let combined = shlex::try_join(args.iter().map(|s| s.as_str()))
187+
.map_err(|e| color_eyre::eyre::eyre!("failed to escape SSH command: {}", e))?;
188+
let status =
189+
run_ephemeral_macos::run_ssh_command(vm.ssh_port, key_path, "root", &combined)?;
190+
if !status.success() {
191+
std::process::exit(status.code().unwrap_or(1));
192+
}
193+
}
194+
Ok(())
195+
}

crates/kit/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ pub mod cpio;
44
pub mod qemu_img;
55
pub mod xml_utils;
66

7+
// Cross-platform modules
8+
pub mod ssh_options;
9+
710
// Linux-only modules
811
#[cfg(target_os = "linux")]
912
pub mod kernel;
13+
14+
// macOS-only modules (vfkit backend)
15+
#[cfg(target_os = "macos")]
16+
pub mod run_ephemeral_macos;
17+
18+
#[cfg(target_os = "macos")]
19+
pub mod vfkit;

crates/kit/src/main.rs

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod cpio;
1111
mod install_options;
1212
mod instancetypes;
1313
mod qemu_img;
14+
mod ssh_options;
1415
mod xml_utils;
1516

1617
// Linux-only modules
@@ -60,6 +61,14 @@ mod utils;
6061
#[cfg(target_os = "linux")]
6162
mod varlink_ipc;
6263

64+
// macOS-only modules (vfkit backend)
65+
#[cfg(target_os = "macos")]
66+
mod ephemeral_macos;
67+
#[cfg(target_os = "macos")]
68+
mod run_ephemeral_macos;
69+
#[cfg(target_os = "macos")]
70+
mod vfkit;
71+
6372
/// Default state directory for bcvk container data
6473
#[cfg(target_os = "linux")]
6574
pub const CONTAINER_STATEDIR: &str = "/var/lib/bcvk";
@@ -104,8 +113,8 @@ enum InternalsCmds {
104113
DumpCliJson,
105114
}
106115

107-
/// Stub subcommands for macOS (shows error message when run)
108-
#[cfg(not(target_os = "linux"))]
116+
/// Stub subcommands for unsupported platforms
117+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
109118
#[derive(Debug, Subcommand)]
110119
pub enum StubEphemeralCommands {
111120
/// Run bootc containers as ephemeral VMs
@@ -139,9 +148,21 @@ enum Commands {
139148
#[clap(subcommand)]
140149
Ephemeral(ephemeral::EphemeralCommands),
141150

142-
// macOS stub: ephemeral command exists but errors out
143-
#[cfg(not(target_os = "linux"))]
144-
/// Run bootc images as stateless VMs via QEMU+Podman (not available on this platform)
151+
// macOS: vfkit-based ephemeral VMs
152+
#[cfg(target_os = "macos")]
153+
/// Manage ephemeral VMs for bootc containers (vfkit backend)
154+
#[clap(subcommand)]
155+
Ephemeral(ephemeral_macos::EphemeralCommands),
156+
157+
// macOS: vfkit-based persistent VMs
158+
#[cfg(target_os = "macos")]
159+
/// Manage persistent VMs (vfkit backend)
160+
#[clap(subcommand)]
161+
Vm(vfkit::VmCommands),
162+
163+
// Other platforms: stub
164+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
165+
/// Manage ephemeral VMs for bootc containers (not available on this platform)
145166
#[clap(subcommand)]
146167
Ephemeral(StubEphemeralCommands),
147168

@@ -284,13 +305,17 @@ fn main() -> Result<(), Report> {
284305
#[cfg(target_os = "linux")]
285306
Commands::Ephemeral(cmd) => cmd.run()?,
286307

287-
// macOS stub: ephemeral command exists but errors out
288-
#[cfg(not(target_os = "linux"))]
308+
#[cfg(target_os = "macos")]
309+
Commands::Ephemeral(cmd) => cmd.run()?,
310+
311+
#[cfg(target_os = "macos")]
312+
Commands::Vm(cmd) => cmd.run()?,
313+
314+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
289315
Commands::Ephemeral(_) => {
290316
return Err(color_eyre::eyre::eyre!(
291-
"The 'ephemeral' command is not available on macOS.\n\
292-
bcvk requires Linux with KVM/QEMU for VM operations.\n\
293-
See https://github.com/bootc-dev/bcvk/issues/21 for more information."
317+
"The 'ephemeral' command is not available on this platform.\n\
318+
bcvk requires Linux with KVM/QEMU or macOS with vfkit for VM operations."
294319
));
295320
}
296321

0 commit comments

Comments
 (0)