use anyhow::{Context, Result};
use arcbox_connect::v1 as pb;
use arcbox_connect::v1::MacosServiceClient;
use arcbox_connect::v1::{
CreateMacosMachineRequest, Empty, InspectMacosMachineRequest, MacosImagePullRequest,
MacosImageRemoveRequest, MacosImageResolveRequest, RemoveMacosMachineRequest,
StartMacosMachineRequest, StopMacosMachineRequest,
};
use clap::{Args, Subcommand};
fn macos_client() -> MacosServiceClient<connectrpc::client::SharedHttp2Connection> {
let (transport, config) = crate::connect::daemon(&super::resolve_grpc_socket_path());
MacosServiceClient::new(transport, config)
}
fn title_case(state: &str) -> String {
let mut chars = state.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => String::new(),
}
}
#[derive(Subcommand)]
pub enum MacosCommands {
Create(CreateArgs),
Start(NameArgs),
Stop(NameArgs),
#[command(alias = "rm")]
Remove(RemoveArgs),
#[command(name = "ls", alias = "list")]
List,
Ip(IpArgs),
#[command(subcommand)]
Image(ImageCommands),
}
#[derive(Args)]
pub struct CreateArgs {
pub name: String,
#[arg(long)]
pub image: String,
#[arg(long, default_value = "4", value_parser = clap::value_parser!(u32).range(1..))]
pub cpus: u32,
#[arg(long, default_value = "8192")]
pub memory: u64,
}
#[derive(Args)]
pub struct NameArgs {
pub name: String,
}
#[derive(Args)]
pub struct IpArgs {
pub name: String,
#[arg(long, default_value = "0")]
pub wait: u16,
}
#[derive(Args)]
pub struct RemoveArgs {
pub name: String,
#[arg(short, long)]
pub force: bool,
}
#[derive(Subcommand)]
pub enum ImageCommands {
Pull(SourceArgs),
Resolve(SourceArgs),
#[command(name = "ls", alias = "list")]
List,
#[command(alias = "rm")]
Remove(ImageRemoveArgs),
}
#[derive(Args)]
pub struct SourceArgs {
#[arg(required_unless_present = "manifest", conflicts_with = "manifest")]
pub reference: Option<String>,
#[arg(long)]
pub manifest: Option<String>,
}
#[derive(Args)]
pub struct ImageRemoveArgs {
pub name: String,
}
pub async fn execute(cmd: MacosCommands) -> Result<()> {
match cmd {
MacosCommands::Create(args) => execute_create(args).await,
MacosCommands::Start(args) => execute_start(args).await,
MacosCommands::Stop(args) => execute_stop(args).await,
MacosCommands::Remove(args) => execute_remove(args).await,
MacosCommands::List => execute_list().await,
MacosCommands::Ip(args) => execute_ip(args).await,
MacosCommands::Image(c) => execute_image(c).await,
}
}
async fn execute_ip(args: IpArgs) -> Result<()> {
let client = macos_client();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(args.wait.into());
loop {
let info: pb::MacosMachineInfo = client
.inspect(InspectMacosMachineRequest {
name: args.name.clone(),
..Default::default()
})
.await
.context("Failed to inspect macOS guest")?
.into_owned();
if !info.ip_address.is_empty() {
println!("{}", info.ip_address);
return Ok(());
}
if std::time::Instant::now() >= deadline {
let hint = if info.state == "running" {
"the guest has not acquired a DHCP lease yet — try --wait 30"
} else {
"is the guest running?"
};
anyhow::bail!("no IP address found for '{}' ({hint})", args.name);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
async fn execute_create(args: CreateArgs) -> Result<()> {
let client = macos_client();
client
.create(CreateMacosMachineRequest {
name: args.name.clone(),
image: args.image.clone(),
cpus: args.cpus,
memory_mib: args.memory,
..Default::default()
})
.await
.context("Failed to create macOS guest")?;
println!(
"macOS guest '{}' created from image '{}'",
args.name, args.image
);
println!(" CPUs: {}", args.cpus);
println!(" Memory: {} MiB", args.memory);
println!();
println!("To start it, run:");
println!(" abctl macos start {}", args.name);
Ok(())
}
async fn execute_start(args: NameArgs) -> Result<()> {
let client = macos_client();
println!("Starting macOS guest '{}'...", args.name);
client
.start(StartMacosMachineRequest {
name: args.name.clone(),
..Default::default()
})
.await
.context("Failed to start macOS guest")?;
println!("macOS guest '{}' started", args.name);
Ok(())
}
async fn execute_stop(args: NameArgs) -> Result<()> {
let client = macos_client();
println!("Stopping macOS guest '{}'...", args.name);
client
.stop(StopMacosMachineRequest {
name: args.name.clone(),
..Default::default()
})
.await
.context("Failed to stop macOS guest")?;
println!("macOS guest '{}' stopped", args.name);
Ok(())
}
async fn execute_remove(args: RemoveArgs) -> Result<()> {
let client = macos_client();
client
.remove(RemoveMacosMachineRequest {
name: args.name.clone(),
force: args.force,
..Default::default()
})
.await
.context("Failed to remove macOS guest")?;
println!("macOS guest '{}' removed", args.name);
Ok(())
}
async fn execute_list() -> Result<()> {
let client = macos_client();
let machines = client
.list(Empty::default())
.await
.context("Failed to list macOS guests")?
.into_owned()
.machines;
if machines.is_empty() {
println!("No macOS guests found.");
println!();
println!("To create one, run:");
println!(" abctl macos create <name> --image <base>");
return Ok(());
}
println!(
"{:<20} {:<12} {:<6} {:<12} {:<16}",
"NAME", "STATE", "CPUS", "MEMORY", "IMAGE"
);
for m in &machines {
println!(
"{:<20} {:<12} {:<6} {:<12} {:<16}",
m.name,
title_case(&m.state),
m.cpus,
format!("{} MiB", m.memory_mib),
m.image,
);
}
Ok(())
}
async fn execute_image(cmd: ImageCommands) -> Result<()> {
match cmd {
ImageCommands::Pull(args) => {
use std::io::Write as _;
let client = macos_client();
let what = args
.reference
.clone()
.or_else(|| args.manifest.clone())
.unwrap_or_default();
let mut stream = client
.image_pull(MacosImagePullRequest {
reference: args.reference.unwrap_or_default(),
manifest_url: args.manifest.unwrap_or_default(),
..Default::default()
})
.await
.context("Failed to pull macOS image")?;
let mut last_stage = String::new();
let mut landed = None;
while let Some(item) = stream
.message::<pb::MacosImagePullEvent>()
.await
.context("Failed to pull macOS image")?
{
let event = item.to_owned_message();
if let Some(image) = event.image.into_option() {
landed = Some(image);
continue;
}
if event.stage != last_stage && !last_stage.is_empty() {
println!();
}
last_stage.clone_from(&event.stage);
print!("\r{}: {:>3.0}%", event.stage, event.fraction * 100.0);
let _ = std::io::stdout().flush();
}
println!();
match landed {
Some(image) => println!("macOS image '{}@{}' pulled", image.name, image.version),
None => println!("macOS image '{what}' pulled"),
}
Ok(())
}
ImageCommands::Resolve(args) => {
let client = macos_client();
let info: pb::MacosImageResolveResponse = client
.image_resolve(MacosImageResolveRequest {
reference: args.reference.unwrap_or_default(),
manifest_url: args.manifest.unwrap_or_default(),
..Default::default()
})
.await
.context("Failed to resolve macOS image")?
.into_owned();
println!("{}@{}", info.name, info.version);
println!("os: macOS {}", info.os_version);
println!(
"requires: {} CPUs, {} MiB memory, {} GB disk",
info.minimum_cpu_count, info.minimum_memory_mib, info.disk_gb
);
if info.installed_version.is_empty() {
println!("installed: (none)");
} else if info.installed_version == info.version {
println!("installed: {} (up to date)", info.installed_version);
} else {
println!("installed: {} (update available)", info.installed_version);
}
Ok(())
}
ImageCommands::List => {
let client = macos_client();
let images = client
.image_list(Empty::default())
.await
.context("Failed to list macOS images")?
.into_owned()
.images;
if images.is_empty() {
println!("No macOS images found.");
println!();
println!("To pull one, run:");
println!(" abctl macos image pull tahoe-base");
return Ok(());
}
println!(
"{:<20} {:<14} {:<8} {:<8} {:<12}",
"NAME", "VERSION", "OS", "DISK", "CREATED"
);
for image in &images {
let created = chrono::DateTime::from_timestamp(image.created, 0)
.map(|t| t.format("%Y-%m-%d").to_string())
.unwrap_or_default();
println!(
"{:<20} {:<14} {:<8} {:<8} {:<12}",
image.name,
if image.version.is_empty() {
"-"
} else {
&image.version
},
if image.os_version.is_empty() {
"-"
} else {
&image.os_version
},
format!("{} GB", image.disk_gb),
created,
);
}
Ok(())
}
ImageCommands::Remove(args) => {
let client = macos_client();
client
.image_remove(MacosImageRemoveRequest {
name: args.name.clone(),
..Default::default()
})
.await
.context("Failed to remove macOS image")?;
println!("macOS image '{}' removed", args.name);
Ok(())
}
}
}