Add multi-network zones and list-based IPAM pools

Templates now define named `net` blocks instead of a single pool
reference, allowing zones like a router to attach to both internal and
public networks. Pools support an `addresses` block with explicit IPs
as an alternative to contiguous range-start/range-end — useful for
hoster-assigned public addresses.

Default init now includes a router template (internal + public) and a
public pool with example addresses. Zone registry entries store per-net
address/VNIC/stub/gateway. Import parses multiple net blocks from
zonecfg info. Backward compatible with legacy single-pool templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till Wegmueller 2026-03-22 14:04:15 +01:00
parent 50a6586f90
commit 430be11b13
No known key found for this signature in database
8 changed files with 646 additions and 201 deletions

View file

@ -20,11 +20,8 @@ The original VM scripts create ZFS volumes (`zfs create -V`). zmgr doesn't manag
### No Cloud-Init / Sysding Integration
The VM scripts generate cloud-init configs (user-data, meta-data, network-config). Zones don't use cloud-init but could benefit from sysding config generation for first-boot setup (hostname, SSH keys, networking).
### No Dry-Run Mode
`zmgr create --dry-run` could show what would happen without executing system commands. Useful for validation.
### No VNIC Naming Customization
VNICs are always `<zonename>0`. Could support custom VNIC naming patterns.
VNICs are always `<zonename><index>`. Could support custom VNIC naming patterns per net.
### Import Matching is Best-Effort
Import matches zones to templates by brand and IPs to pools by network containment. Zones with unusual configs may get poor matches. Manual editing of the resulting KDL files may be needed.
@ -32,6 +29,12 @@ Import matches zones to templates by brand and IPs to pools by network containme
### No IPv6 Support
IPAM only handles IPv4 pools. Could extend to dual-stack.
## Resolved
- ~~No Dry-Run Mode~~ — Implemented: `--dry-run` / `-n` flag on create and destroy
- ~~Single network per zone~~ — Implemented: templates define multiple `net` blocks, each referencing a pool
- ~~No public/hoster IP support~~ — Implemented: pools support explicit address lists in addition to contiguous ranges
## Future Considerations
- **Zone ordering**: Dependencies between zones (e.g., start DNS zone before app zones)

View file

@ -6,17 +6,22 @@
- [x] KDL parsing helpers (`kdl_util.rs`)
- [x] Error types with miette diagnostics (`error.rs`)
- [x] Global config loading (`config.rs`)
- [x] Template loading + defaults (`template.rs`)
- [x] IPAM pool loading, allocation, defaults (`pool.rs`)
- [x] Zone registry CRUD (`zone.rs`)
- [x] Template loading + defaults — multi-net support (`template.rs`)
- [x] IPAM pool loading, allocation, defaults — range + list modes (`pool.rs`)
- [x] Zone registry CRUD — multi-net support (`zone.rs`)
- [x] Publisher management (`publisher.rs`)
- [x] Exec layer for system commands (`exec.rs`)
- [x] Import logic from existing zones (`import.rs`)
- [x] Import logic from existing zones — multi-net parsing (`import.rs`)
- [x] CLI commands: init, create, destroy, list, status, import
- [x] CLI commands: template list/show, pool list/show
- [x] CLI commands: publisher list/add/remove
- [x] `--dry-run` / `-n` flag for create and destroy
- [x] Multi-network zones (multiple net blocks per template/zone)
- [x] List-based IPAM pools (explicit addresses from hoster)
- [x] Default router template with internal + public nets
- [x] Backward compat: legacy flat `pool` field in templates, flat address fields in zones
- [x] Clean build (zero warnings)
- [x] Tested: init, template list, pool list/show, publisher list, list
- [x] Tested: init, template list/show, pool list/show, publisher list, list, dry-run single+multi-net
## Not Yet Tested on illumos

View file

@ -16,13 +16,31 @@ default-template "oi"
## templates/*.kdl
Templates define one or more `net` blocks, each referencing an IPAM pool.
```kdl
// Single-net template
template "oi" {
brand "ipkg"
autoboot #false
ip-type "exclusive"
net "internal" {
pool "internal"
}
}
// Multi-net template (e.g., router with internal + public)
template "router" {
brand "ipkg"
autoboot #true
ip-type "exclusive"
net "internal" {
pool "internal"
}
net "public" {
pool "public"
}
}
```
| Field | Type | Required | Description |
@ -30,10 +48,16 @@ template "oi" {
| `brand` | string | yes | Zone brand (ipkg, nlipkg, etc.) |
| `autoboot` | bool | no | Boot zone after install (default: `#false`) |
| `ip-type` | string | no | IP type (default: `exclusive`) |
| `pool` | string | yes | IPAM pool name to allocate from |
| `net "<name>"` | block | yes (1+) | Network attachment with pool reference |
Legacy: a flat `pool "name"` field is accepted as shorthand for a single net named "default".
## pools/*.kdl
Pools support two address source modes: **range** or **list**.
### Range-based pool (contiguous)
```kdl
pool "internal" {
network "10.1.0.0/24"
@ -44,28 +68,60 @@ pool "internal" {
}
```
### List-based pool (specific addresses)
For public IPs from a hoster where addresses may not be contiguous:
```kdl
pool "public" {
network "203.0.113.0/28"
gateway "203.0.113.1"
stub "pubstub0"
addresses {
address "203.0.113.2"
address "203.0.113.3"
address "203.0.113.5"
}
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `network` | CIDR | yes | Network in CIDR notation |
| `gateway` | IPv4 | yes | Default router for zones in this pool |
| `stub` | string | yes | Etherstub/VNIC parent for zone VNICs |
| `range-start` | IPv4 | yes | First allocatable address |
| `range-end` | IPv4 | yes | Last allocatable address |
| `range-start` | IPv4 | * | First allocatable address (range mode) |
| `range-end` | IPv4 | * | Last allocatable address (range mode) |
| `addresses` | block | * | Explicit address list (list mode) |
\* Exactly one of range (`range-start` + `range-end`) or `addresses` is required.
## zones/*.kdl
Zone registry entries store per-net address/VNIC/stub/gateway:
```kdl
zone "myzone" {
template "oi"
zone "gateway" {
template "router"
created "2026-03-22"
net "internal" {
address "10.1.0.10/24"
gateway "10.1.0.1"
vnic "myzone0"
vnic "gateway0"
stub "oinetint0"
created "2026-03-22"
}
net "public" {
address "203.0.113.2/28"
gateway "203.0.113.1"
vnic "gateway1"
stub "pubstub0"
}
}
```
These files are created by `zmgr create` and `zmgr import`. They serve as the IPAM allocation ledger — scanning all zone files determines which IPs are in use.
These files are created by `zmgr create` and `zmgr import`. They serve as the IPAM allocation ledger — scanning all zone net blocks determines which IPs are in use.
VNIC naming: `<zonename><index>` where index is the position in the net list (0, 1, 2...).
## publishers/*.kdl

View file

@ -4,7 +4,7 @@ use crate::error::Result;
use crate::exec;
use crate::pool::Pool;
use crate::template::Template;
use crate::zone::Zone;
use crate::zone::{Zone, ZoneNet};
/// Import existing zones from the system into the registry.
///
@ -45,27 +45,16 @@ pub fn import_zones(registry: &Path, filter_name: Option<&str>) -> Result<Vec<St
}
};
// Parse zonecfg info output
let address = parse_zonecfg_field(&info, "allowed-address:")
.unwrap_or_default();
let vnic = parse_zonecfg_field(&info, "physical:")
.unwrap_or_else(|| format!("{}0", entry.name));
let gateway = parse_zonecfg_field(&info, "defrouter:")
.unwrap_or_default();
// Parse all network interfaces from zonecfg info
let nets = parse_zonecfg_nets(&info, &entry.name, &pools, registry);
// Match to a template by brand
let template = match_template(&entry.brand, &templates, registry);
// Match to a pool by finding which pool's network contains the address
let stub = match_pool_stub(&address, &pools, registry);
let zone = Zone {
name: entry.name.clone(),
template: template.unwrap_or_else(|| entry.brand.clone()),
address,
gateway,
vnic,
stub: stub.unwrap_or_default(),
nets,
created: String::new(), // unknown for imported zones
};
@ -76,19 +65,100 @@ pub fn import_zones(registry: &Path, filter_name: Option<&str>) -> Result<Vec<St
Ok(imported)
}
/// Parse a field from `zonecfg info` output.
/// Lines look like: `\tset allowed-address=10.1.0.5/24` or `\tallowed-address: 10.1.0.5/24`
fn parse_zonecfg_field(info: &str, field: &str) -> Option<String> {
/// Parse network interfaces from `zonecfg info` output.
///
/// zonecfg info outputs net blocks like:
/// ```text
/// net:
/// allowed-address: 10.1.0.5/24
/// physical: myzone0
/// defrouter: 10.1.0.1
/// ```
fn parse_zonecfg_nets(
info: &str,
zone_name: &str,
pool_names: &[String],
registry: &Path,
) -> Vec<ZoneNet> {
let mut nets = Vec::new();
let mut in_net = false;
let mut address = String::new();
let mut physical = String::new();
let mut defrouter = String::new();
for line in info.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(field) {
let value = rest.trim().trim_matches('"');
if !value.is_empty() {
return Some(value.to_string());
if trimmed == "net:" {
// Flush previous net if we had one
if in_net && (!address.is_empty() || !physical.is_empty()) {
let stub = match_pool_stub(&address, pool_names, registry);
nets.push(ZoneNet {
name: format!("net{}", nets.len()),
address: address.clone(),
gateway: defrouter.clone(),
vnic: physical.clone(),
stub: stub.unwrap_or_default(),
});
}
in_net = true;
address.clear();
physical.clear();
defrouter.clear();
continue;
}
// A new non-net section ends the net block
if !trimmed.starts_with('[') && trimmed.ends_with(':') && trimmed != "net:" {
if in_net && (!address.is_empty() || !physical.is_empty()) {
let stub = match_pool_stub(&address, pool_names, registry);
nets.push(ZoneNet {
name: format!("net{}", nets.len()),
address: address.clone(),
gateway: defrouter.clone(),
vnic: physical.clone(),
stub: stub.unwrap_or_default(),
});
}
in_net = false;
continue;
}
if in_net {
if let Some(val) = trimmed.strip_prefix("allowed-address:") {
address = val.trim().trim_matches('"').to_string();
} else if let Some(val) = trimmed.strip_prefix("physical:") {
physical = val.trim().trim_matches('"').to_string();
} else if let Some(val) = trimmed.strip_prefix("defrouter:") {
defrouter = val.trim().trim_matches('"').to_string();
}
}
}
None
// Flush last net block
if in_net && (!address.is_empty() || !physical.is_empty()) {
let stub = match_pool_stub(&address, pool_names, registry);
nets.push(ZoneNet {
name: format!("net{}", nets.len()),
address,
gateway: defrouter,
vnic: physical,
stub: stub.unwrap_or_default(),
});
}
// If no nets found, create a placeholder
if nets.is_empty() {
nets.push(ZoneNet {
name: "default".to_string(),
address: String::new(),
gateway: String::new(),
vnic: format!("{zone_name}0"),
stub: String::new(),
});
}
nets
}
/// Match a brand to a template name.

View file

@ -23,7 +23,7 @@ use crate::error::{Result, ZmgrError};
use crate::pool::Pool;
use crate::publisher::Publisher;
use crate::template::Template;
use crate::zone::Zone;
use crate::zone::{Zone, ZoneNet};
#[derive(Parser)]
#[command(name = "zmgr", about = "illumos zone manager")]
@ -173,8 +173,8 @@ fn cmd_init(registry: &Path, force: bool) -> Result<()> {
println!("Initialized zmgr registry at {}", registry.display());
println!(" config.kdl — global settings");
println!(" templates/ — zone templates (oi, ofl)");
println!(" pools/ — IPAM pools (internal, ofl)");
println!(" templates/ — zone templates (oi, ofl, router)");
println!(" pools/ — IPAM pools (internal, ofl, public)");
println!(" publishers/ — IPS publishers (openindiana)");
println!(" zones/ — zone registry (empty)");
Ok(())
@ -198,47 +198,76 @@ fn cmd_create(
let cfg = Config::load(registry)?;
let tmpl_name = template_name.unwrap_or(&cfg.default_template);
let tmpl = Template::load(registry, tmpl_name)?;
let pool = Pool::load(registry, &tmpl.pool)?;
// Allocate next free IP
// Collect all allocated IPs across all zones
let zones = Zone::list(registry)?;
let ip = pool.allocate(&zones)?;
let used_ips = Zone::all_allocated_ips(&zones);
// Resolve each network attachment: load pool, allocate IP, name VNIC
let mut zone_nets = Vec::new();
for (idx, tnet) in tmpl.nets.iter().enumerate() {
let pool = Pool::load(registry, &tnet.pool)?;
let ip = pool.allocate(&used_ips)?;
let prefix_len = pool.network.prefix_len();
let address = format!("{ip}/{prefix_len}");
let vnic = format!("{name}0");
let vnic = format!("{name}{idx}");
let gateway = pool.gateway.to_string();
let stub = pool.stub.clone();
zone_nets.push((tnet, pool, ZoneNet {
name: tnet.name.clone(),
address,
gateway,
vnic,
stub,
}));
}
// Build zonecfg commands
let autoboot = if tmpl.autoboot { "true" } else { "false" };
let zonecfg_cmds = format!(
let mut zonecfg_cmds = format!(
"create -b\n\
set zonepath={}/{name}\n\
set brand={}\n\
set autoboot={autoboot}\n\
set ip-type={}\n\
add net\n\
set physical={vnic}\n\
set allowed-address={address}\n\
set defrouter={}\n\
end\n\
verify\n\
commit\n",
cfg.zonepath_prefix, tmpl.brand, tmpl.ip_type, pool.gateway
set ip-type={}\n",
cfg.zonepath_prefix, tmpl.brand, tmpl.ip_type
);
for (_, _, znet) in &zone_nets {
zonecfg_cmds.push_str(&format!(
"add net\n\
set physical={}\n\
set allowed-address={}\n\
set defrouter={}\n\
end\n",
znet.vnic, znet.address, znet.gateway
));
}
zonecfg_cmds.push_str("verify\ncommit\n");
if dry_run {
println!("[dry-run] Would create zone '{name}'");
println!();
println!(" template: {tmpl_name}");
println!(" brand: {}", tmpl.brand);
println!(" address: {address}");
println!(" vnic: {vnic} (on stub {})", pool.stub);
println!(" gateway: {}", pool.gateway);
println!(" zonepath: {}/{name}", cfg.zonepath_prefix);
println!(" autoboot: {}", tmpl.autoboot);
println!(" networks: {}", zone_nets.len());
println!();
for (tnet, pool, znet) in &zone_nets {
println!(" net \"{}\" (pool: {})", tnet.name, tnet.pool);
println!(" address: {}", znet.address);
println!(" gateway: {}", znet.gateway);
println!(" vnic: {} (on stub {})", znet.vnic, pool.stub);
}
println!();
println!("Commands that would be executed:");
println!();
println!(" dladm create-vnic -l {} {vnic}", pool.stub);
for (_, pool, znet) in &zone_nets {
println!(" dladm create-vnic -l {} {}", pool.stub, znet.vnic);
}
println!();
println!(" zonecfg -z {name} <<EOF");
for line in zonecfg_cmds.lines() {
@ -256,21 +285,24 @@ fn cmd_create(
return Ok(());
}
// Create VNIC
exec::dladm_create_vnic(&vnic, &pool.stub)?;
// Create VNICs
for (_, _, znet) in &zone_nets {
exec::dladm_create_vnic(&znet.vnic, &znet.stub)?;
}
exec::zonecfg_create(name, &zonecfg_cmds)?;
exec::zoneadm_install(name)?;
// Write registry entry
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let nets: Vec<ZoneNet> = zone_nets
.into_iter()
.map(|(_, _, znet)| znet)
.collect();
let zone = Zone {
name: name.to_string(),
template: tmpl_name.to_string(),
address: address.clone(),
gateway: pool.gateway.to_string(),
vnic: vnic.clone(),
stub: pool.stub.clone(),
nets,
created: today,
};
zone.write(registry)?;
@ -278,9 +310,9 @@ fn cmd_create(
println!("Created zone '{name}'");
println!(" template: {tmpl_name}");
println!(" brand: {}", tmpl.brand);
println!(" address: {address}");
println!(" vnic: {vnic}");
println!(" gateway: {}", pool.gateway);
for net in &zone.nets {
println!(" net \"{}\": {} via {} ({})", net.name, net.address, net.vnic, net.gateway);
}
if tmpl.autoboot {
exec::zoneadm_boot(name)?;
@ -302,15 +334,18 @@ fn cmd_destroy(registry: &Path, name: &str, dry_run: bool) -> Result<()> {
println!("[dry-run] Would destroy zone '{name}'");
println!();
println!(" template: {}", zone.template);
println!(" address: {}", zone.address);
println!(" vnic: {}", zone.vnic);
for net in &zone.nets {
println!(" net \"{}\": {} via {}", net.name, net.address, net.vnic);
}
println!();
println!("Commands that would be executed:");
println!();
println!(" zoneadm -z {name} halt");
println!(" zoneadm -z {name} uninstall -F");
println!(" zonecfg -z {name} delete -F");
println!(" dladm delete-vnic {}", zone.vnic);
for net in &zone.nets {
println!(" dladm delete-vnic {}", net.vnic);
}
println!();
println!("Registry entry that would be removed:");
println!(" {}/zones/{name}.kdl", registry.display());
@ -330,9 +365,11 @@ fn cmd_destroy(registry: &Path, name: &str, dry_run: bool) -> Result<()> {
eprintln!("warning: zonecfg delete: {e}");
}
// Delete VNIC
if let Err(e) = exec::dladm_delete_vnic(&zone.vnic) {
eprintln!("warning: dladm delete-vnic: {e}");
// Delete all VNICs
for net in &zone.nets {
if let Err(e) = exec::dladm_delete_vnic(&net.vnic) {
eprintln!("warning: dladm delete-vnic {}: {e}", net.vnic);
}
}
// Remove from registry
@ -352,13 +389,17 @@ fn cmd_list(registry: &Path) -> Result<()> {
}
println!(
"{:<20} {:<10} {:<20} {:<15}",
"NAME", "TEMPLATE", "ADDRESS", "VNIC"
"{:<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} {:<20} {:<15}",
z.name, z.template, z.address, z.vnic
"{:<20} {:<10} {:<10} {}",
z.name,
z.template,
z.nets.len(),
addrs.join(", ")
);
}
Ok(())
@ -380,16 +421,23 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
println!(" template: {}", r.template);
println!(" brand: {}", s.brand);
println!(" state: {}", s.state);
println!(" address: {}", r.address);
println!(" vnic: {}", r.vnic);
println!(" path: {}", s.path);
println!(" uuid: {}", s.uuid);
println!(" created: {}", r.created);
println!(" networks:");
for net in &r.nets {
println!(
" {:<12} {} via {} (gw {})",
net.name, net.address, net.vnic, net.gateway
);
}
}
(Some(r), None) => {
println!("Zone: {name} (in registry but not on system)");
println!(" template: {}", r.template);
println!(" address: {}", r.address);
for net in &r.nets {
println!(" {:<12} {}", net.name, net.address);
}
}
(None, Some(s)) => {
println!("Zone: {name} (on system but not in registry)");
@ -406,8 +454,8 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
}
} else {
println!(
"{:<20} {:<10} {:<12} {:<20} {:<10}",
"NAME", "TEMPLATE", "STATE", "ADDRESS", "REGISTRY"
"{:<20} {:<10} {:<12} {:<10} {}",
"NAME", "TEMPLATE", "STATE", "REGISTRY", "ADDRESSES"
);
let mut seen = HashSet::new();
@ -418,9 +466,10 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
.find(|s| s.name == r.name)
.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} {:<20} {:<10}",
r.name, r.template, state, r.address, "yes"
"{:<20} {:<10} {:<12} {:<10} {}",
r.name, r.template, state, "yes", addrs.join(", ")
);
seen.insert(r.name.clone());
}
@ -428,8 +477,8 @@ fn cmd_status(registry: &Path, name: Option<&str>) -> Result<()> {
for s in &system_zones {
if !seen.contains(&s.name) && s.brand != "bhyve" {
println!(
"{:<20} {:<10} {:<12} {:<20} {:<10}",
s.name, s.brand, s.state, "-", "no"
"{:<20} {:<10} {:<12} {:<10} {}",
s.name, s.brand, s.state, "no", "-"
);
}
}
@ -468,9 +517,17 @@ fn cmd_template_list(registry: &Path) -> Result<()> {
}
for name in &names {
let tmpl = Template::load(registry, name)?;
let net_desc: Vec<String> = tmpl
.nets
.iter()
.map(|n| format!("{}:{}", n.name, n.pool))
.collect();
println!(
"{:<10} brand={:<10} pool={:<10} autoboot={}",
name, tmpl.brand, tmpl.pool, tmpl.autoboot
"{:<10} brand={:<10} nets={:<25} autoboot={}",
name,
tmpl.brand,
net_desc.join(","),
tmpl.autoboot
);
}
Ok(())
@ -484,7 +541,10 @@ fn cmd_template_show(registry: &Path, name: &str) -> Result<()> {
println!(" brand: {}", tmpl.brand);
println!(" autoboot: {}", tmpl.autoboot);
println!(" ip-type: {}", tmpl.ip_type);
println!(" pool: {}", tmpl.pool);
println!(" networks:");
for net in &tmpl.nets {
println!(" {:<12} pool={}", net.name, net.pool);
}
Ok(())
}
@ -498,20 +558,15 @@ fn cmd_pool_list(registry: &Path) -> Result<()> {
}
let zones = Zone::list(registry)?;
let used_ips = Zone::all_allocated_ips(&zones);
for name in &names {
let pool = Pool::load(registry, name)?;
let allocated: usize = zones
let allocated = used_ips
.iter()
.filter(|z| {
z.address
.split('/')
.next()
.and_then(|ip| ip.parse::<Ipv4Addr>().ok())
.is_some_and(|ip| pool.network.contains(&ip))
})
.filter(|ip| pool.network.contains(*ip))
.count();
let total = u32::from(pool.range_end) - u32::from(pool.range_start) + 1;
let total = pool.total_addresses();
println!(
"{:<10} network={:<18} stub={:<12} used={}/{}",
name, pool.network, pool.stub, allocated, total
@ -530,25 +585,31 @@ fn cmd_pool_show(registry: &Path, name: &str) -> Result<()> {
println!(" network: {}", pool.network);
println!(" gateway: {}", pool.gateway);
println!(" stub: {}", pool.stub);
println!(" range: {} - {}", pool.range_start, pool.range_end);
println!(" addresses: {}", pool.source_description());
let allocated: Vec<&Zone> = zones
.iter()
.filter(|z| {
z.address
// Find allocations: zone nets whose IP falls in this pool
let mut allocations = Vec::new();
for z in &zones {
for net in &z.nets {
if let Some(ip) = net
.address
.split('/')
.next()
.and_then(|ip| ip.parse::<Ipv4Addr>().ok())
.is_some_and(|ip| pool.network.contains(&ip))
})
.collect();
.and_then(|s| s.parse::<Ipv4Addr>().ok())
{
if pool.network.contains(&ip) {
allocations.push((&z.name, &net.name, &net.address));
}
}
}
}
if allocated.is_empty() {
if allocations.is_empty() {
println!(" allocations: none");
} else {
println!(" allocations:");
for z in &allocated {
println!(" {} -> {}", z.address, z.name);
for (zone_name, net_name, addr) in &allocations {
println!(" {addr} -> {zone_name} ({net_name})");
}
}

View file

@ -5,7 +5,17 @@ use ipnet::Ipv4Net;
use crate::error::{Result, ZmgrError};
use crate::kdl_util;
use crate::zone::Zone;
/// How addresses are defined in the pool.
pub enum AddressSource {
/// Contiguous range (range-start to range-end).
Range {
start: Ipv4Addr,
end: Ipv4Addr,
},
/// Explicit list of addresses.
List(Vec<Ipv4Addr>),
}
/// An IPAM address pool.
pub struct Pool {
@ -13,8 +23,7 @@ pub struct Pool {
pub network: Ipv4Net,
pub gateway: Ipv4Addr,
pub stub: String,
pub range_start: Ipv4Addr,
pub range_end: Ipv4Addr,
pub source: AddressSource,
}
impl Pool {
@ -65,9 +74,45 @@ impl Pool {
context: ctx.clone(),
})?;
// Determine address source: explicit list or range
let source = if let Some(addr_node) = children.get("addresses") {
// Explicit address list
let addr_children = addr_node.children().ok_or_else(|| ZmgrError::MissingField {
field: "addresses children".to_string(),
context: ctx.clone(),
})?;
let mut addrs = Vec::new();
for node in addr_children.nodes() {
if node.name().to_string() == "address" {
let ip_str = node
.entries()
.first()
.and_then(|e| e.value().as_string())
.ok_or_else(|| ZmgrError::MissingField {
field: "address value".to_string(),
context: ctx.clone(),
})?;
let ip: Ipv4Addr =
ip_str.parse().map_err(|_| ZmgrError::MissingField {
field: format!("address (invalid IP: {ip_str})"),
context: ctx.clone(),
})?;
addrs.push(ip);
}
}
if addrs.is_empty() {
return Err(ZmgrError::MissingField {
field: "at least one address".to_string(),
context: ctx,
}
.into());
}
AddressSource::List(addrs)
} else {
// Range-based
let range_start_str = kdl_util::get_string(children, "range-start")
.ok_or_else(|| ZmgrError::MissingField {
field: "range-start".to_string(),
field: "range-start (or addresses block)".to_string(),
context: ctx.clone(),
})?;
let range_start: Ipv4Addr =
@ -91,47 +136,91 @@ impl Pool {
context: ctx.clone(),
})?;
AddressSource::Range {
start: range_start,
end: range_end,
}
};
Ok(Pool {
name: name.to_string(),
network,
gateway,
stub,
range_start,
range_end,
source,
})
}
/// Allocate the next free IP from this pool, given existing zones.
pub fn allocate(&self, zones: &[Zone]) -> Result<Ipv4Addr> {
let allocated: Vec<Ipv4Addr> = zones
/// Allocate the next free IP from this pool.
/// `used` is the set of IPs already allocated across all zones.
pub fn allocate(&self, used: &[Ipv4Addr]) -> Result<Ipv4Addr> {
let in_pool: Vec<Ipv4Addr> = used
.iter()
.filter_map(|z| {
// address is stored as "x.x.x.x/prefix"
z.address
.split('/')
.next()
.and_then(|ip| ip.parse::<Ipv4Addr>().ok())
})
.copied()
.filter(|ip| self.network.contains(ip))
.collect();
let start = u32::from(self.range_start);
let end = u32::from(self.range_end);
for candidate in start..=end {
match &self.source {
AddressSource::Range { start, end } => {
let s = u32::from(*start);
let e = u32::from(*end);
for candidate in s..=e {
let ip = Ipv4Addr::from(candidate);
if !allocated.contains(&ip) {
if !in_pool.contains(&ip) {
return Ok(ip);
}
}
Err(ZmgrError::PoolExhausted {
pool: self.name.clone(),
range_start: self.range_start.to_string(),
range_end: self.range_end.to_string(),
range_start: start.to_string(),
range_end: end.to_string(),
}
.into())
}
AddressSource::List(addrs) => {
for addr in addrs {
if !in_pool.contains(addr) {
return Ok(*addr);
}
}
let first = addrs.first().unwrap();
let last = addrs.last().unwrap();
Err(ZmgrError::PoolExhausted {
pool: self.name.clone(),
range_start: first.to_string(),
range_end: last.to_string(),
}
.into())
}
}
}
/// Total number of allocatable addresses.
pub fn total_addresses(&self) -> u32 {
match &self.source {
AddressSource::Range { start, end } => {
u32::from(*end) - u32::from(*start) + 1
}
AddressSource::List(addrs) => addrs.len() as u32,
}
}
/// Human-readable description of the address source.
pub fn source_description(&self) -> String {
match &self.source {
AddressSource::Range { start, end } => {
format!("{start} - {end}")
}
AddressSource::List(addrs) => {
if addrs.len() <= 5 {
let strs: Vec<String> = addrs.iter().map(|a| a.to_string()).collect();
strs.join(", ")
} else {
format!("{} addresses", addrs.len())
}
}
}
}
/// List all pool names.
pub fn list(registry: &Path) -> Result<Vec<String>> {
@ -184,6 +273,25 @@ impl Pool {
)
.map_err(ZmgrError::Io)?;
// Example public pool with explicit address list
std::fs::write(
dir.join("public.kdl"),
r#"// Public addresses from hoster — list each usable IP
pool "public" {
network "203.0.113.0/28"
gateway "203.0.113.1"
stub "pubstub0"
addresses {
address "203.0.113.2"
address "203.0.113.3"
address "203.0.113.4"
address "203.0.113.5"
}
}
"#,
)
.map_err(ZmgrError::Io)?;
Ok(())
}
}

View file

@ -3,6 +3,14 @@ use std::path::Path;
use crate::error::{Result, ZmgrError};
use crate::kdl_util;
/// A network attachment in a template.
pub struct TemplateNet {
/// Logical name for this network (used in zone registry and VNIC naming).
pub name: String,
/// IPAM pool to allocate from.
pub pool: String,
}
/// A zone creation template.
#[allow(dead_code)]
pub struct Template {
@ -10,7 +18,7 @@ pub struct Template {
pub brand: String,
pub autoboot: bool,
pub ip_type: String,
pub pool: String,
pub nets: Vec<TemplateNet>,
}
impl Template {
@ -26,7 +34,6 @@ impl Template {
let doc = kdl_util::read_document(&path)?;
let ctx = format!("template '{name}'");
// The template node wraps the children
let template_node = doc.get("template").ok_or_else(|| ZmgrError::MissingField {
field: "template".to_string(),
context: ctx.clone(),
@ -47,18 +54,62 @@ impl Template {
let ip_type = kdl_util::get_string(children, "ip-type")
.unwrap_or_else(|| "exclusive".to_string());
let pool = kdl_util::get_string(children, "pool")
// Parse network attachments: either `net` blocks or legacy `pool` field
let mut nets = Vec::new();
for node in children.nodes() {
if node.name().to_string() == "net" {
let net_name = node
.entries()
.first()
.and_then(|e| e.value().as_string())
.ok_or_else(|| ZmgrError::MissingField {
field: "pool".to_string(),
field: "net name argument".to_string(),
context: ctx.clone(),
})?
.to_string();
let net_children = node.children().ok_or_else(|| ZmgrError::MissingField {
field: format!("net '{net_name}' children"),
context: ctx.clone(),
})?;
let pool = kdl_util::get_string(net_children, "pool")
.ok_or_else(|| ZmgrError::MissingField {
field: format!("pool in net '{net_name}'"),
context: ctx.clone(),
})?;
nets.push(TemplateNet {
name: net_name,
pool,
});
}
}
// Backward compat: if no `net` blocks, look for a single `pool` field
if nets.is_empty() {
if let Some(pool) = kdl_util::get_string(children, "pool") {
nets.push(TemplateNet {
name: "default".to_string(),
pool,
});
}
}
if nets.is_empty() {
return Err(ZmgrError::MissingField {
field: "at least one net block (or pool field)".to_string(),
context: ctx,
}
.into());
}
Ok(Template {
name: name.to_string(),
brand,
autoboot,
ip_type,
pool,
nets,
})
}
@ -87,14 +138,17 @@ impl Template {
let dir = registry.join("templates");
std::fs::create_dir_all(&dir).map_err(ZmgrError::Io)?;
// Single-net templates (backward compatible with pool field)
std::fs::write(
dir.join("oi.kdl"),
r#"template "oi" {
brand "ipkg"
autoboot #false
ip-type "exclusive"
net "internal" {
pool "internal"
}
}
"#,
)
.map_err(ZmgrError::Io)?;
@ -105,8 +159,28 @@ impl Template {
brand "nlipkg"
autoboot #true
ip-type "exclusive"
net "ofl" {
pool "ofl"
}
}
"#,
)
.map_err(ZmgrError::Io)?;
// Multi-net template example: router with internal + public
std::fs::write(
dir.join("router.kdl"),
r#"template "router" {
brand "ipkg"
autoboot #true
ip-type "exclusive"
net "internal" {
pool "internal"
}
net "public" {
pool "public"
}
}
"#,
)
.map_err(ZmgrError::Io)?;

View file

@ -1,16 +1,24 @@
use std::net::Ipv4Addr;
use std::path::Path;
use crate::error::{Result, ZmgrError};
use crate::kdl_util;
/// A managed zone registry entry.
pub struct Zone {
/// A network attachment in a zone registry entry.
pub struct ZoneNet {
/// Logical name (matches the template net name).
pub name: String,
pub template: String,
pub address: String,
pub gateway: String,
pub vnic: String,
pub stub: String,
}
/// A managed zone registry entry.
pub struct Zone {
pub name: String,
pub template: String,
pub nets: Vec<ZoneNet>,
pub created: String,
}
@ -51,20 +59,64 @@ impl Zone {
context: ctx.clone(),
})?;
let template = kdl_util::get_string(children, "template").unwrap_or_default();
let created = kdl_util::get_string(children, "created").unwrap_or_default();
// Parse net blocks
let mut nets = Vec::new();
for node in children.nodes() {
if node.name().to_string() == "net" {
let net_name = node
.entries()
.first()
.and_then(|e| e.value().as_string())
.unwrap_or("default")
.to_string();
let net_children = node.children();
let (address, gateway, vnic, stub) = if let Some(nc) = net_children {
(
kdl_util::get_string(nc, "address").unwrap_or_default(),
kdl_util::get_string(nc, "gateway").unwrap_or_default(),
kdl_util::get_string(nc, "vnic").unwrap_or_default(),
kdl_util::get_string(nc, "stub").unwrap_or_default(),
)
} else {
Default::default()
};
nets.push(ZoneNet {
name: net_name,
address,
gateway,
vnic,
stub,
});
}
}
// Backward compat: flat address/vnic/stub/gateway fields (single net)
if nets.is_empty() {
let address = kdl_util::get_string(children, "address").unwrap_or_default();
let gateway = kdl_util::get_string(children, "gateway").unwrap_or_default();
let vnic = kdl_util::get_string(children, "vnic").unwrap_or_default();
let stub = kdl_util::get_string(children, "stub").unwrap_or_default();
if !address.is_empty() || !vnic.is_empty() {
nets.push(ZoneNet {
name: "default".to_string(),
address,
gateway,
vnic,
stub,
});
}
}
Ok(Zone {
name: name.clone(),
template: kdl_util::get_string(children, "template")
.unwrap_or_default(),
address: kdl_util::get_string(children, "address")
.unwrap_or_default(),
gateway: kdl_util::get_string(children, "gateway")
.unwrap_or_default(),
vnic: kdl_util::get_string(children, "vnic")
.unwrap_or_default(),
stub: kdl_util::get_string(children, "stub")
.unwrap_or_default(),
created: kdl_util::get_string(children, "created")
.unwrap_or_default(),
name,
template,
nets,
created,
})
}
@ -73,18 +125,20 @@ impl Zone {
let dir = registry.join("zones");
std::fs::create_dir_all(&dir).map_err(ZmgrError::Io)?;
let path = dir.join(format!("{}.kdl", self.name));
let content = format!(
r#"zone "{}" {{
template "{}"
address "{}"
gateway "{}"
vnic "{}"
stub "{}"
created "{}"
}}
"#,
self.name, self.template, self.address, self.gateway, self.vnic, self.stub, self.created
);
let mut content = format!("zone \"{}\" {{\n", self.name);
content.push_str(&format!(" template \"{}\"\n", self.template));
content.push_str(&format!(" created \"{}\"\n", self.created));
for net in &self.nets {
content.push_str(&format!(" net \"{}\" {{\n", net.name));
content.push_str(&format!(" address \"{}\"\n", net.address));
content.push_str(&format!(" gateway \"{}\"\n", net.gateway));
content.push_str(&format!(" vnic \"{}\"\n", net.vnic));
content.push_str(&format!(" stub \"{}\"\n", net.stub));
content.push_str(" }\n");
}
content.push_str("}\n");
std::fs::write(&path, content).map_err(ZmgrError::Io)?;
Ok(())
}
@ -121,6 +175,20 @@ impl Zone {
Ok(zones)
}
/// Collect all allocated IPs across all zones' nets.
pub fn all_allocated_ips(zones: &[Zone]) -> Vec<Ipv4Addr> {
zones
.iter()
.flat_map(|z| &z.nets)
.filter_map(|net| {
net.address
.split('/')
.next()
.and_then(|ip| ip.parse::<Ipv4Addr>().ok())
})
.collect()
}
/// Check if a zone exists in the registry.
pub fn exists(registry: &Path, name: &str) -> bool {
registry