diff --git a/src/import.rs b/src/import.rs index 7f602d4..d7a5da5 100644 --- a/src/import.rs +++ b/src/import.rs @@ -46,16 +46,28 @@ pub fn import_zones(registry: &Path, filter_name: Option<&str>) -> Result miette::Result<()> { let dry_run = cli.dry_run; let yes = cli.yes; + let json = cli.json; match cli.command { Commands::Init { force } => cmd_init(®istry, force), @@ -151,20 +166,22 @@ fn main() -> miette::Result<()> { cmd_create(®istry, &name, template.as_deref(), dry_run) } Commands::Destroy { name } => cmd_destroy(®istry, &name, dry_run, yes), - Commands::List => cmd_list(®istry), - Commands::Status { name } => cmd_status(®istry, name.as_deref()), + Commands::Boot { name } => cmd_boot(®istry, &name), + Commands::Halt { name } => cmd_halt(®istry, &name), + Commands::List => cmd_list(®istry, json), + Commands::Status { name } => cmd_status(®istry, name.as_deref(), json), Commands::Import { name } => cmd_import(®istry, name.as_deref()), Commands::Validate => cmd_validate(®istry), Commands::Template { action } => match action { - TemplateAction::List => cmd_template_list(®istry), - TemplateAction::Show { name } => cmd_template_show(®istry, &name), + TemplateAction::List => cmd_template_list(®istry, json), + TemplateAction::Show { name } => cmd_template_show(®istry, &name, json), }, Commands::Pool { action } => match action { - PoolAction::List => cmd_pool_list(®istry), - PoolAction::Show { name } => cmd_pool_show(®istry, &name), + PoolAction::List => cmd_pool_list(®istry, json), + PoolAction::Show { name } => cmd_pool_show(®istry, &name, json), }, Commands::Publisher { action } => match action { - PublisherAction::List => cmd_publisher_list(®istry), + PublisherAction::List => cmd_publisher_list(®istry, json), PublisherAction::Add { name, origin } => cmd_publisher_add(®istry, &name, &origin), PublisherAction::Remove { name } => cmd_publisher_remove(®istry, &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]) { + let col_count = headers.len(); + let mut widths: Vec = 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!("{: Result<()> { config::ensure_initialized(registry)?; let zones = Zone::list(registry)?; + + if json { + let out: Vec = 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::>(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&out).unwrap()); + return Ok(()); + } + if zones.is_empty() { println!("No managed zones. Use `zmgr create ` or `zmgr import`."); return Ok(()); } - println!( - "{:<20} {:<10} {:<10} {}", - "NAME", "TEMPLATE", "NETS", "ADDRESSES" - ); - for z in &zones { - 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(", ") - ); - } + let rows: Vec> = zones + .iter() + .map(|z| { + let addrs: Vec<&str> = z.nets.iter().map(|n| n.address.as_str()).collect(); + 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 = 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 ®istry_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::>(), + })); + 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 ®istry_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::>(), + })); + } + 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 = 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::>(), + }); + 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::>(), + }); + 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(()) } diff --git a/src/pool.rs b/src/pool.rs index 5ad66b9..d5de08a 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -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" diff --git a/src/publisher.rs b/src/publisher.rs index eb53f7e..5687a96 100644 --- a/src/publisher.rs +++ b/src/publisher.rs @@ -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(()) }