Improve DX: JSON output, boot/halt, tables, publisher fix

Batch 2 of developer experience improvements:

- Add --json flag for machine-readable output on list/show/status commands
- Add boot and halt subcommands as thin wrappers around zoneadm
- Publisher remove now accepts publisher name (not just filename stem)
- Tables auto-size columns based on content instead of hardcoded widths
- Import uses current date for created field and matches template net names
- Default public pool includes RFC 5737 documentation comment
- Template list uses proper table format with separate columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till Wegmueller 2026-03-22 15:34:04 +01:00
parent 382823b228
commit 6e2915fdc9
No known key found for this signature in database
4 changed files with 354 additions and 71 deletions

View file

@ -46,16 +46,28 @@ pub fn import_zones(registry: &Path, filter_name: Option<&str>) -> Result<Vec<St
}; };
// Parse all network interfaces from zonecfg info // Parse all network interfaces from zonecfg info
let nets = parse_zonecfg_nets(&info, &entry.name, &pools, registry); let mut nets = parse_zonecfg_nets(&info, &entry.name, &pools, registry);
// Match to a template by brand // Match to a template by brand
let template = match_template(&entry.brand, &templates, registry); let template_name = match_template(&entry.brand, &templates, registry);
// Try to use template net names if we matched a template
if let Some(ref tname) = template_name {
if let Ok(tmpl) = crate::template::Template::load(registry, tname) {
if tmpl.nets.len() == nets.len() {
for (i, tnet) in tmpl.nets.iter().enumerate() {
nets[i].name = tnet.name.clone();
}
}
}
}
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let zone = Zone { let zone = Zone {
name: entry.name.clone(), name: entry.name.clone(),
template: template.unwrap_or_else(|| entry.brand.clone()), template: template_name.unwrap_or_else(|| entry.brand.clone()),
nets, nets,
created: String::new(), // unknown for imported zones created: today,
}; };
zone.write(registry)?; zone.write(registry)?;

View file

@ -43,6 +43,10 @@ struct Cli {
#[arg(long, short = 'y')] #[arg(long, short = 'y')]
yes: bool, yes: bool,
/// Output in JSON format (for scripting)
#[arg(long)]
json: bool,
#[command(subcommand)] #[command(subcommand)]
command: Commands, command: Commands,
} }
@ -68,6 +72,16 @@ enum Commands {
/// Zone name /// Zone name
name: String, name: String,
}, },
/// Boot a zone
Boot {
/// Zone name
name: String,
},
/// Halt a zone
Halt {
/// Zone name
name: String,
},
/// List managed zones /// List managed zones
List, List,
/// Show zone status (registry + system state) /// Show zone status (registry + system state)
@ -144,6 +158,7 @@ fn main() -> miette::Result<()> {
let dry_run = cli.dry_run; let dry_run = cli.dry_run;
let yes = cli.yes; let yes = cli.yes;
let json = cli.json;
match cli.command { match cli.command {
Commands::Init { force } => cmd_init(&registry, force), Commands::Init { force } => cmd_init(&registry, force),
@ -151,20 +166,22 @@ fn main() -> miette::Result<()> {
cmd_create(&registry, &name, template.as_deref(), dry_run) cmd_create(&registry, &name, template.as_deref(), dry_run)
} }
Commands::Destroy { name } => cmd_destroy(&registry, &name, dry_run, yes), Commands::Destroy { name } => cmd_destroy(&registry, &name, dry_run, yes),
Commands::List => cmd_list(&registry), Commands::Boot { name } => cmd_boot(&registry, &name),
Commands::Status { name } => cmd_status(&registry, name.as_deref()), Commands::Halt { name } => cmd_halt(&registry, &name),
Commands::List => cmd_list(&registry, json),
Commands::Status { name } => cmd_status(&registry, name.as_deref(), json),
Commands::Import { name } => cmd_import(&registry, name.as_deref()), Commands::Import { name } => cmd_import(&registry, name.as_deref()),
Commands::Validate => cmd_validate(&registry), Commands::Validate => cmd_validate(&registry),
Commands::Template { action } => match action { Commands::Template { action } => match action {
TemplateAction::List => cmd_template_list(&registry), TemplateAction::List => cmd_template_list(&registry, json),
TemplateAction::Show { name } => cmd_template_show(&registry, &name), TemplateAction::Show { name } => cmd_template_show(&registry, &name, json),
}, },
Commands::Pool { action } => match action { Commands::Pool { action } => match action {
PoolAction::List => cmd_pool_list(&registry), PoolAction::List => cmd_pool_list(&registry, json),
PoolAction::Show { name } => cmd_pool_show(&registry, &name), PoolAction::Show { name } => cmd_pool_show(&registry, &name, json),
}, },
Commands::Publisher { action } => match action { Commands::Publisher { action } => match action {
PublisherAction::List => cmd_publisher_list(&registry), PublisherAction::List => cmd_publisher_list(&registry, json),
PublisherAction::Add { name, origin } => cmd_publisher_add(&registry, &name, &origin), PublisherAction::Add { name, origin } => cmd_publisher_add(&registry, &name, &origin),
PublisherAction::Remove { name } => cmd_publisher_remove(&registry, &name), PublisherAction::Remove { name } => cmd_publisher_remove(&registry, &name),
}, },
@ -574,33 +591,120 @@ fn cmd_destroy(registry: &Path, name: &str, dry_run: bool, yes: bool) -> Result<
Ok(()) Ok(())
} }
fn cmd_list(registry: &Path) -> Result<()> { fn cmd_boot(registry: &Path, name: &str) -> Result<()> {
config::ensure_initialized(registry)?;
if !Zone::exists(registry, name) {
return Err(ZmgrError::ZoneNotFound {
name: name.to_string(),
}
.into());
}
exec::zoneadm_boot(name)?;
println!("Zone '{name}' booted.");
Ok(())
}
fn cmd_halt(registry: &Path, name: &str) -> Result<()> {
config::ensure_initialized(registry)?;
if !Zone::exists(registry, name) {
return Err(ZmgrError::ZoneNotFound {
name: name.to_string(),
}
.into());
}
exec::zoneadm_halt(name)?;
println!("Zone '{name}' halted.");
Ok(())
}
// --- Table helper ---
/// Print a table with auto-sized columns. Last column is unbounded.
fn print_table(headers: &[&str], rows: &[Vec<String>]) {
let col_count = headers.len();
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < col_count - 1 {
// Don't measure last column (unbounded)
widths[i] = widths[i].max(cell.len());
}
}
}
// Print header
for (i, h) in headers.iter().enumerate() {
if i < col_count - 1 {
print!("{:<width$} ", h, width = widths[i]);
} else {
print!("{}", h);
}
}
println!();
// Print rows
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < col_count - 1 {
print!("{:<width$} ", cell, width = widths[i]);
} else {
print!("{}", cell);
}
}
println!();
}
}
fn cmd_list(registry: &Path, json: bool) -> Result<()> {
config::ensure_initialized(registry)?; config::ensure_initialized(registry)?;
let zones = Zone::list(registry)?; let zones = Zone::list(registry)?;
if json {
let out: Vec<serde_json::Value> = zones
.iter()
.map(|z| {
serde_json::json!({
"name": z.name,
"template": z.template,
"created": z.created,
"nets": z.nets.iter().map(|n| serde_json::json!({
"name": n.name,
"address": n.address,
"gateway": n.gateway,
"vnic": n.vnic,
"stub": n.stub,
})).collect::<Vec<_>>(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return Ok(());
}
if zones.is_empty() { if zones.is_empty() {
println!("No managed zones. Use `zmgr create <name>` or `zmgr import`."); println!("No managed zones. Use `zmgr create <name>` or `zmgr import`.");
return Ok(()); return Ok(());
} }
println!( let rows: Vec<Vec<String>> = zones
"{:<20} {:<10} {:<10} {}", .iter()
"NAME", "TEMPLATE", "NETS", "ADDRESSES" .map(|z| {
);
for z in &zones {
let addrs: Vec<&str> = z.nets.iter().map(|n| n.address.as_str()).collect(); let addrs: Vec<&str> = z.nets.iter().map(|n| n.address.as_str()).collect();
println!( vec![
"{:<20} {:<10} {:<10} {}", z.name.clone(),
z.name, z.template.clone(),
z.template, z.nets.len().to_string(),
z.nets.len(), addrs.join(", "),
addrs.join(", ") ]
); })
} .collect();
print_table(&["NAME", "TEMPLATE", "NETS", "ADDRESSES"], &rows);
Ok(()) Ok(())
} }
fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> { fn cmd_status(registry: &Path, name: Option<&str>, json: bool) -> Result<()> {
config::ensure_initialized(registry)?; config::ensure_initialized(registry)?;
let system_zones = exec::zoneadm_list()?; let system_zones = exec::zoneadm_list()?;
@ -610,6 +714,34 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
let reg = registry_zones.iter().find(|z| z.name == name); let reg = registry_zones.iter().find(|z| z.name == name);
let sys = system_zones.iter().find(|z| z.name == name); let sys = system_zones.iter().find(|z| z.name == name);
if json {
let mut obj = serde_json::Map::new();
obj.insert("name".into(), name.into());
if let Some(r) = reg {
obj.insert("template".into(), r.template.clone().into());
obj.insert("created".into(), r.created.clone().into());
obj.insert("in_registry".into(), true.into());
let nets: Vec<serde_json::Value> = r.nets.iter().map(|n| serde_json::json!({
"name": n.name, "address": n.address,
"gateway": n.gateway, "vnic": n.vnic, "stub": n.stub,
})).collect();
obj.insert("nets".into(), nets.into());
} else {
obj.insert("in_registry".into(), false.into());
}
if let Some(s) = sys {
obj.insert("brand".into(), s.brand.clone().into());
obj.insert("state".into(), s.state.clone().into());
obj.insert("path".into(), s.path.clone().into());
obj.insert("uuid".into(), s.uuid.clone().into());
obj.insert("on_system".into(), true.into());
} else {
obj.insert("on_system".into(), false.into());
}
println!("{}", serde_json::to_string_pretty(&serde_json::Value::Object(obj)).unwrap());
return Ok(());
}
match (reg, sys) { match (reg, sys) {
(Some(r), Some(s)) => { (Some(r), Some(s)) => {
println!("Zone: {name}"); println!("Zone: {name}");
@ -648,11 +780,32 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
} }
} }
} else { } else {
println!( if json {
"{:<20} {:<10} {:<12} {:<10} {}", let mut entries = Vec::new();
"NAME", "TEMPLATE", "STATE", "REGISTRY", "ADDRESSES" let mut seen = HashSet::new();
); for r in &registry_zones {
let state = system_zones.iter().find(|s| s.name == r.name)
.map(|s| s.state.as_str()).unwrap_or("absent");
entries.push(serde_json::json!({
"name": r.name, "template": r.template, "state": state,
"in_registry": true,
"addresses": r.nets.iter().map(|n| &n.address).collect::<Vec<_>>(),
}));
seen.insert(r.name.clone());
}
for s in &system_zones {
if !seen.contains(&s.name) && s.brand != "bhyve" {
entries.push(serde_json::json!({
"name": s.name, "brand": s.brand, "state": s.state,
"in_registry": false,
}));
}
}
println!("{}", serde_json::to_string_pretty(&entries).unwrap());
return Ok(());
}
let mut rows = Vec::new();
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for r in &registry_zones { for r in &registry_zones {
@ -662,22 +815,29 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
.map(|s| s.state.as_str()) .map(|s| s.state.as_str())
.unwrap_or("absent"); .unwrap_or("absent");
let addrs: Vec<&str> = r.nets.iter().map(|n| n.address.as_str()).collect(); let addrs: Vec<&str> = r.nets.iter().map(|n| n.address.as_str()).collect();
println!( rows.push(vec![
"{:<20} {:<10} {:<12} {:<10} {}", r.name.clone(),
r.name, r.template, state, "yes", r.template.clone(),
addrs.join(", ") state.to_string(),
); "yes".to_string(),
addrs.join(", "),
]);
seen.insert(r.name.clone()); seen.insert(r.name.clone());
} }
for s in &system_zones { for s in &system_zones {
if !seen.contains(&s.name) && s.brand != "bhyve" { if !seen.contains(&s.name) && s.brand != "bhyve" {
println!( rows.push(vec![
"{:<20} {:<10} {:<12} {:<10} {}", s.name.clone(),
s.name, s.brand, s.state, "no", "-" s.brand.clone(),
); s.state.clone(),
"no".to_string(),
"-".to_string(),
]);
} }
} }
print_table(&["NAME", "TEMPLATE", "STATE", "REGISTRY", "ADDRESSES"], &rows);
} }
Ok(()) Ok(())
@ -715,14 +875,35 @@ fn cmd_validate(registry: &Path) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_template_list(registry: &Path) -> Result<()> { fn cmd_template_list(registry: &Path, json: bool) -> Result<()> {
config::ensure_initialized(registry)?; config::ensure_initialized(registry)?;
let names = Template::list(registry)?; let names = Template::list(registry)?;
if json {
let mut out = Vec::new();
for name in &names {
let tmpl = Template::load(registry, name)?;
out.push(serde_json::json!({
"name": name,
"brand": tmpl.brand,
"autoboot": tmpl.autoboot,
"ip_type": tmpl.ip_type,
"nets": tmpl.nets.iter().map(|n| serde_json::json!({
"name": n.name, "pool": n.pool,
})).collect::<Vec<_>>(),
}));
}
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return Ok(());
}
if names.is_empty() { if names.is_empty() {
println!("No templates defined."); println!("No templates defined.");
return Ok(()); return Ok(());
} }
let mut rows = Vec::new();
for name in &names { for name in &names {
let tmpl = Template::load(registry, name)?; let tmpl = Template::load(registry, name)?;
let net_desc: Vec<String> = tmpl let net_desc: Vec<String> = tmpl
@ -730,21 +911,36 @@ fn cmd_template_list(registry: &Path) -> Result<()> {
.iter() .iter()
.map(|n| format!("{}:{}", n.name, n.pool)) .map(|n| format!("{}:{}", n.name, n.pool))
.collect(); .collect();
println!( rows.push(vec![
"{:<10} brand={:<10} nets={:<25} autoboot={}", name.clone(),
name,
tmpl.brand, tmpl.brand,
net_desc.join(", "), net_desc.join(", "),
tmpl.autoboot tmpl.autoboot.to_string(),
); ]);
} }
print_table(&["NAME", "BRAND", "NETS", "AUTOBOOT"], &rows);
Ok(()) Ok(())
} }
fn cmd_template_show(registry: &Path, name: &str) -> Result<()> { fn cmd_template_show(registry: &Path, name: &str, json: bool) -> Result<()> {
config::ensure_initialized(registry)?; config::ensure_initialized(registry)?;
let tmpl = Template::load(registry, name)?; let tmpl = Template::load(registry, name)?;
if json {
let out = serde_json::json!({
"name": name,
"brand": tmpl.brand,
"autoboot": tmpl.autoboot,
"ip_type": tmpl.ip_type,
"nets": tmpl.nets.iter().map(|n| serde_json::json!({
"name": n.name, "pool": n.pool,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return Ok(());
}
println!("Template: {name}"); println!("Template: {name}");
println!(" brand: {}", tmpl.brand); println!(" brand: {}", tmpl.brand);
println!(" autoboot: {}", tmpl.autoboot); println!(" autoboot: {}", tmpl.autoboot);
@ -756,45 +952,58 @@ fn cmd_template_show(registry: &Path, name: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_pool_list(registry: &Path) -> Result<()> { fn cmd_pool_list(registry: &Path, json: bool) -> Result<()> {
config::ensure_initialized(registry)?; config::ensure_initialized(registry)?;
let names = Pool::list(registry)?; let names = Pool::list(registry)?;
let zones = Zone::list(registry)?;
let used_ips = Zone::all_allocated_ips(&zones);
if json {
let mut out = Vec::new();
for name in &names {
let pool = Pool::load(registry, name)?;
let allocated = used_ips.iter().filter(|ip| pool.network.contains(*ip)).count();
out.push(serde_json::json!({
"name": name,
"network": pool.network.to_string(),
"gateway": pool.gateway.to_string(),
"stub": pool.stub,
"allocated": allocated,
"total": pool.total_addresses(),
}));
}
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return Ok(());
}
if names.is_empty() { if names.is_empty() {
println!("No pools defined."); println!("No pools defined.");
return Ok(()); return Ok(());
} }
let zones = Zone::list(registry)?; let mut rows = Vec::new();
let used_ips = Zone::all_allocated_ips(&zones);
for name in &names { for name in &names {
let pool = Pool::load(registry, name)?; let pool = Pool::load(registry, name)?;
let allocated = used_ips let allocated = used_ips.iter().filter(|ip| pool.network.contains(*ip)).count();
.iter()
.filter(|ip| pool.network.contains(*ip))
.count();
let total = pool.total_addresses(); let total = pool.total_addresses();
println!( rows.push(vec![
"{:<10} network={:<18} stub={:<12} used={}/{}", name.clone(),
name, pool.network, pool.stub, allocated, total pool.network.to_string(),
); pool.stub.clone(),
format!("{}/{}", allocated, total),
]);
} }
print_table(&["NAME", "NETWORK", "STUB", "USED"], &rows);
Ok(()) Ok(())
} }
fn cmd_pool_show(registry: &Path, name: &str) -> Result<()> { fn cmd_pool_show(registry: &Path, name: &str, json: bool) -> Result<()> {
config::ensure_initialized(registry)?; config::ensure_initialized(registry)?;
let pool = Pool::load(registry, name)?; let pool = Pool::load(registry, name)?;
let zones = Zone::list(registry)?; let zones = Zone::list(registry)?;
println!("Pool: {name}");
println!(" network: {}", pool.network);
println!(" gateway: {}", pool.gateway);
println!(" stub: {}", pool.stub);
println!(" addresses: {}", pool.source_description());
let mut allocations = Vec::new(); let mut allocations = Vec::new();
for z in &zones { for z in &zones {
for net in &z.nets { for net in &z.nets {
@ -811,6 +1020,28 @@ fn cmd_pool_show(registry: &Path, name: &str) -> Result<()> {
} }
} }
if json {
let out = serde_json::json!({
"name": name,
"network": pool.network.to_string(),
"gateway": pool.gateway.to_string(),
"stub": pool.stub,
"addresses": pool.source_description(),
"total": pool.total_addresses(),
"allocations": allocations.iter().map(|(zn, nn, addr)| serde_json::json!({
"address": addr, "zone": zn, "net": nn,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return Ok(());
}
println!("Pool: {name}");
println!(" network: {}", pool.network);
println!(" gateway: {}", pool.gateway);
println!(" stub: {}", pool.stub);
println!(" addresses: {}", pool.source_description());
if allocations.is_empty() { if allocations.is_empty() {
println!(" allocations: none"); println!(" allocations: none");
} else { } else {
@ -823,18 +1054,35 @@ fn cmd_pool_show(registry: &Path, name: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_publisher_list(registry: &Path) -> Result<()> { fn cmd_publisher_list(registry: &Path, json: bool) -> Result<()> {
config::ensure_initialized(registry)?; config::ensure_initialized(registry)?;
let names = Publisher::list(registry)?; let names = Publisher::list(registry)?;
if json {
let mut out = Vec::new();
for name in &names {
let pub_ = Publisher::load(registry, name)?;
out.push(serde_json::json!({
"name": pub_.name,
"origin": pub_.origin,
}));
}
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return Ok(());
}
if names.is_empty() { if names.is_empty() {
println!("No publishers configured."); println!("No publishers configured.");
return Ok(()); return Ok(());
} }
let mut rows = Vec::new();
for name in &names { for name in &names {
let pub_ = Publisher::load(registry, name)?; let pub_ = Publisher::load(registry, name)?;
println!("{:<30} {}", pub_.name, pub_.origin); rows.push(vec![pub_.name, pub_.origin]);
} }
print_table(&["NAME", "ORIGIN"], &rows);
Ok(()) Ok(())
} }

View file

@ -276,7 +276,8 @@ impl Pool {
// Example public pool with explicit address list // Example public pool with explicit address list
std::fs::write( std::fs::write(
dir.join("public.kdl"), dir.join("public.kdl"),
r#"// Public addresses from hoster — list each usable IP r#"// Example public pool — replace with your hoster's actual addresses
// 203.0.113.0/28 is RFC 5737 documentation range, not routable
pool "public" { pool "public" {
network "203.0.113.0/28" network "203.0.113.0/28"
gateway "203.0.113.1" gateway "203.0.113.1"

View file

@ -92,13 +92,35 @@ impl Publisher {
Ok(()) Ok(())
} }
/// Remove a publisher by filename stem. /// Remove a publisher by filename stem or publisher name.
pub fn remove(registry: &Path, name: &str) -> Result<()> { pub fn remove(registry: &Path, name: &str) -> Result<()> {
let dir = registry.join("publishers"); let dir = registry.join("publishers");
// Try exact filename stem first
let path = dir.join(format!("{name}.kdl")); let path = dir.join(format!("{name}.kdl"));
if path.exists() { if path.exists() {
std::fs::remove_file(&path).map_err(ZmgrError::Io)?; std::fs::remove_file(&path).map_err(ZmgrError::Io)?;
return Ok(());
} }
// Try matching by publisher name inside the KDL files
if dir.exists() {
for entry in std::fs::read_dir(&dir).map_err(ZmgrError::Io)? {
let entry = entry.map_err(ZmgrError::Io)?;
let fpath = entry.path();
if fpath.extension().is_some_and(|e| e == "kdl") {
if let Some(stem) = fpath.file_stem() {
if let Ok(pub_) = Publisher::load(registry, &stem.to_string_lossy()) {
if pub_.name == name {
std::fs::remove_file(&fpath).map_err(ZmgrError::Io)?;
return Ok(());
}
}
}
}
}
}
Ok(()) Ok(())
} }