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
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
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 {
name: entry.name.clone(),
template: template.unwrap_or_else(|| entry.brand.clone()),
template: template_name.unwrap_or_else(|| entry.brand.clone()),
nets,
created: String::new(), // unknown for imported zones
created: today,
};
zone.write(registry)?;

View file

@ -43,6 +43,10 @@ struct Cli {
#[arg(long, short = 'y')]
yes: bool,
/// Output in JSON format (for scripting)
#[arg(long)]
json: bool,
#[command(subcommand)]
command: Commands,
}
@ -68,6 +72,16 @@ enum Commands {
/// Zone name
name: String,
},
/// Boot a zone
Boot {
/// Zone name
name: String,
},
/// Halt a zone
Halt {
/// Zone name
name: String,
},
/// List managed zones
List,
/// Show zone status (registry + system state)
@ -144,6 +158,7 @@ fn main() -> miette::Result<()> {
let dry_run = cli.dry_run;
let yes = cli.yes;
let json = cli.json;
match cli.command {
Commands::Init { force } => cmd_init(&registry, force),
@ -151,20 +166,22 @@ fn main() -> miette::Result<()> {
cmd_create(&registry, &name, template.as_deref(), dry_run)
}
Commands::Destroy { name } => cmd_destroy(&registry, &name, dry_run, yes),
Commands::List => cmd_list(&registry),
Commands::Status { name } => cmd_status(&registry, name.as_deref()),
Commands::Boot { name } => cmd_boot(&registry, &name),
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::Validate => cmd_validate(&registry),
Commands::Template { action } => match action {
TemplateAction::List => cmd_template_list(&registry),
TemplateAction::Show { name } => cmd_template_show(&registry, &name),
TemplateAction::List => cmd_template_list(&registry, json),
TemplateAction::Show { name } => cmd_template_show(&registry, &name, json),
},
Commands::Pool { action } => match action {
PoolAction::List => cmd_pool_list(&registry),
PoolAction::Show { name } => cmd_pool_show(&registry, &name),
PoolAction::List => cmd_pool_list(&registry, json),
PoolAction::Show { name } => cmd_pool_show(&registry, &name, json),
},
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::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(())
}
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)?;
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() {
println!("No managed zones. Use `zmgr create <name>` or `zmgr import`.");
return Ok(());
}
println!(
"{:<20} {:<10} {:<10} {}",
"NAME", "TEMPLATE", "NETS", "ADDRESSES"
);
for z in &zones {
let rows: Vec<Vec<String>> = zones
.iter()
.map(|z| {
let addrs: Vec<&str> = z.nets.iter().map(|n| n.address.as_str()).collect();
println!(
"{:<20} {:<10} {:<10} {}",
z.name,
z.template,
z.nets.len(),
addrs.join(", ")
);
}
vec![
z.name.clone(),
z.template.clone(),
z.nets.len().to_string(),
addrs.join(", "),
]
})
.collect();
print_table(&["NAME", "TEMPLATE", "NETS", "ADDRESSES"], &rows);
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)?;
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 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) {
(Some(r), Some(s)) => {
println!("Zone: {name}");
@ -648,11 +780,32 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
}
}
} else {
println!(
"{:<20} {:<10} {:<12} {:<10} {}",
"NAME", "TEMPLATE", "STATE", "REGISTRY", "ADDRESSES"
);
if json {
let mut entries = Vec::new();
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();
for r in &registry_zones {
@ -662,22 +815,29 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
.map(|s| s.state.as_str())
.unwrap_or("absent");
let addrs: Vec<&str> = r.nets.iter().map(|n| n.address.as_str()).collect();
println!(
"{:<20} {:<10} {:<12} {:<10} {}",
r.name, r.template, state, "yes",
addrs.join(", ")
);
rows.push(vec![
r.name.clone(),
r.template.clone(),
state.to_string(),
"yes".to_string(),
addrs.join(", "),
]);
seen.insert(r.name.clone());
}
for s in &system_zones {
if !seen.contains(&s.name) && s.brand != "bhyve" {
println!(
"{:<20} {:<10} {:<12} {:<10} {}",
s.name, s.brand, s.state, "no", "-"
);
rows.push(vec![
s.name.clone(),
s.brand.clone(),
s.state.clone(),
"no".to_string(),
"-".to_string(),
]);
}
}
print_table(&["NAME", "TEMPLATE", "STATE", "REGISTRY", "ADDRESSES"], &rows);
}
Ok(())
@ -715,14 +875,35 @@ fn cmd_validate(registry: &Path) -> Result<()> {
Ok(())
}
fn cmd_template_list(registry: &Path) -> Result<()> {
fn cmd_template_list(registry: &Path, json: bool) -> Result<()> {
config::ensure_initialized(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() {
println!("No templates defined.");
return Ok(());
}
let mut rows = Vec::new();
for name in &names {
let tmpl = Template::load(registry, name)?;
let net_desc: Vec<String> = tmpl
@ -730,21 +911,36 @@ fn cmd_template_list(registry: &Path) -> Result<()> {
.iter()
.map(|n| format!("{}:{}", n.name, n.pool))
.collect();
println!(
"{:<10} brand={:<10} nets={:<25} autoboot={}",
name,
rows.push(vec![
name.clone(),
tmpl.brand,
net_desc.join(","),
tmpl.autoboot
);
net_desc.join(", "),
tmpl.autoboot.to_string(),
]);
}
print_table(&["NAME", "BRAND", "NETS", "AUTOBOOT"], &rows);
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)?;
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!(" brand: {}", tmpl.brand);
println!(" autoboot: {}", tmpl.autoboot);
@ -756,45 +952,58 @@ fn cmd_template_show(registry: &Path, name: &str) -> Result<()> {
Ok(())
}
fn cmd_pool_list(registry: &Path) -> Result<()> {
fn cmd_pool_list(registry: &Path, json: bool) -> Result<()> {
config::ensure_initialized(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() {
println!("No pools defined.");
return Ok(());
}
let zones = Zone::list(registry)?;
let used_ips = Zone::all_allocated_ips(&zones);
let mut rows = Vec::new();
for name in &names {
let pool = Pool::load(registry, name)?;
let allocated = used_ips
.iter()
.filter(|ip| pool.network.contains(*ip))
.count();
let allocated = used_ips.iter().filter(|ip| pool.network.contains(*ip)).count();
let total = pool.total_addresses();
println!(
"{:<10} network={:<18} stub={:<12} used={}/{}",
name, pool.network, pool.stub, allocated, total
);
rows.push(vec![
name.clone(),
pool.network.to_string(),
pool.stub.clone(),
format!("{}/{}", allocated, total),
]);
}
print_table(&["NAME", "NETWORK", "STUB", "USED"], &rows);
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)?;
let pool = Pool::load(registry, name)?;
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();
for z in &zones {
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() {
println!(" allocations: none");
} else {
@ -823,18 +1054,35 @@ fn cmd_pool_show(registry: &Path, name: &str) -> Result<()> {
Ok(())
}
fn cmd_publisher_list(registry: &Path) -> Result<()> {
fn cmd_publisher_list(registry: &Path, json: bool) -> Result<()> {
config::ensure_initialized(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() {
println!("No publishers configured.");
return Ok(());
}
let mut rows = Vec::new();
for name in &names {
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(())
}

View file

@ -276,7 +276,8 @@ impl Pool {
// Example public pool with explicit address list
std::fs::write(
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" {
network "203.0.113.0/28"
gateway "203.0.113.1"

View file

@ -92,13 +92,35 @@ impl Publisher {
Ok(())
}
/// Remove a publisher by filename stem.
/// Remove a publisher by filename stem or publisher name.
pub fn remove(registry: &Path, name: &str) -> Result<()> {
let dir = registry.join("publishers");
// Try exact filename stem first
let path = dir.join(format!("{name}.kdl"));
if path.exists() {
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(())
}