Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ lint:

.PHONY: test
test:
python3 tests/fixtures/validate.py
cargo test --all-targets --release
2 changes: 1 addition & 1 deletion docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ nav_order: 8

Major changes:

- KubeVirt: Add support for static IP configuration from cloud-init
- KubeVirt: Add support for static and dynamic IP configuration from cloud-init

Minor changes:

Expand Down
9 changes: 9 additions & 0 deletions src/providers/kubevirt/cloudconfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,15 @@ impl MetadataProvider for KubeVirtCloudConfig {
}
}

// Add static routes for the interface (including DHCP interfaces)
// This allows DHCP interfaces to have static gateway configuration
for route in &iface.routes {
// Only add routes with prefix 0 (default routes)
if route.destination.prefix() == 0 {
kargs.push(format!("rd.route={}:{}", route.destination, route.gateway));
}
}

// Collect nameservers from all interfaces
for nameserver in &iface.nameservers {
if !all_nameservers.contains(nameserver) {
Expand Down
66 changes: 35 additions & 31 deletions src/providers/kubevirt/configdrive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,47 +265,51 @@ impl NetworkData {
}
}

// Collect nameservers
// Collect nameservers from network-specific DNS configuration
for ns in &network.dns_nameservers {
let nameserver = IpAddr::from_str(ns)?;
if !all_nameservers.contains(&nameserver) {
all_nameservers.push(nameserver);
}
}

// Process routes
for route in &network.routes {
// Handle network and netmask according to OpenStack schema
let destination = if route.network == "0.0.0.0" && route.netmask == "0.0.0.0" {
// Default IPv4 route
IpNetwork::from_str("0.0.0.0/0")?
} else if route.network == "::" && route.netmask == "::" {
// Default IPv6 route
IpNetwork::from_str("::/0")?
} else {
// Calculate prefix length from netmask for proper CIDR notation
let network_addr = IpAddr::from_str(&route.network)?;
if let Ok(netmask_addr) = IpAddr::from_str(&route.netmask) {
IpNetwork::with_netmask(network_addr, netmask_addr)?
} else if let Ok(prefix_len) = route.netmask.parse::<u8>() {
IpNetwork::new(network_addr, prefix_len)?
// Process routes — always add routes when present, regardless of network type.
// Per the OpenStack schema, routes are valid on any network type including
// ipv4_dhcp/ipv6_dhcp, allowing static gateway configuration with DHCP addresses.
{
for route in &network.routes {
// Handle network and netmask according to OpenStack schema
let destination = if route.network == "0.0.0.0" && route.netmask == "0.0.0.0" {
// Default IPv4 route
IpNetwork::from_str("0.0.0.0/0")?
} else if route.network == "::" && route.netmask == "::" {
// Default IPv6 route
IpNetwork::from_str("::/0")?
} else {
// For IPv6, netmask might be in full format like "ffff:ffff:ffff:ffff::"
if network_addr.is_ipv6() && route.netmask == "ffff:ffff:ffff:ffff::" {
IpNetwork::new(network_addr, 64)?
// Calculate prefix length from netmask for proper CIDR notation
let network_addr = IpAddr::from_str(&route.network)?;
if let Ok(netmask_addr) = IpAddr::from_str(&route.netmask) {
IpNetwork::with_netmask(network_addr, netmask_addr)?
} else if let Ok(prefix_len) = route.netmask.parse::<u8>() {
IpNetwork::new(network_addr, prefix_len)?
} else {
return Err(anyhow::anyhow!(
"Invalid netmask format: {}. Expected IP address or prefix length.",
route.netmask
));
// For IPv6, netmask might be in full format like "ffff:ffff:ffff:ffff::"
if network_addr.is_ipv6() && route.netmask == "ffff:ffff:ffff:ffff::" {
IpNetwork::new(network_addr, 64)?
} else {
return Err(anyhow::anyhow!(
"Invalid netmask format: {}. Expected IP address or prefix length.",
route.netmask
));
}
}
}
};
let gateway = IpAddr::from_str(&route.gateway)?;
iface.routes.push(NetworkRoute {
destination,
gateway,
});
};
let gateway = IpAddr::from_str(&route.gateway)?;
iface.routes.push(NetworkRoute {
destination,
gateway,
});
}
}
}

Expand Down
150 changes: 112 additions & 38 deletions src/providers/kubevirt/nocloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ pub struct NetworkConfigV1Entry {
pub subnets: Vec<NetworkConfigV1Subnet>,
}

/// Route configuration in v1 format
///
/// Supports both the preferred `destination` (CIDR) format and the
/// OpenStack-compatible `network`/`netmask` alias.
#[derive(Debug, Deserialize)]
pub struct RouteConfigV1 {
/// Destination in CIDR notation (preferred)
pub destination: Option<String>,
/// Destination network (OpenStack alias for `destination`)
pub network: Option<String>,
/// Netmask for the destination network (used with `network`)
pub netmask: Option<String>,
/// Gateway address
pub gateway: String,
}

/// Network Config v1 subnet
#[derive(Debug, Deserialize)]
pub struct NetworkConfigV1Subnet {
Expand All @@ -80,6 +96,9 @@ pub struct NetworkConfigV1Subnet {
/// DNS nameservers
#[serde(default)]
pub dns_nameservers: Vec<String>,
/// Routes (for static configuration)
#[serde(default)]
pub routes: Vec<RouteConfigV1>,
}

/// Network Config v2 format
Expand All @@ -98,6 +117,29 @@ pub struct NetworkConfigV2 {
pub nameservers: Option<NameserversConfig>,
}

/// DHCP overrides configuration
///
/// These fields are parsed for schema compatibility with netplan/cloud-init v2
/// format. Afterburn generates dracut kernel arguments and does not control
/// DHCP client behavior directly — the overrides are consumed by the
/// networking stack (e.g., NetworkManager/systemd-networkd) at boot time.
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct DhcpOverrides {
#[serde(rename = "use-dns", default)]
pub use_dns: Option<bool>,
#[serde(rename = "use-routes", default)]
pub use_routes: Option<bool>,
#[serde(rename = "use-domains", default)]
pub use_domains: Option<bool>,
#[serde(rename = "use-hostname", default)]
pub use_hostname: Option<bool>,
#[serde(rename = "use-ntp", default)]
pub use_ntp: Option<bool>,
#[serde(rename = "route-metric", default)]
pub route_metric: Option<u32>,
}

/// Ethernet interface configuration in v2 format
#[derive(Debug, Deserialize)]
pub struct EthernetConfigV2 {
Expand All @@ -107,6 +149,14 @@ pub struct EthernetConfigV2 {
/// DHCP for IPv6
#[serde(default)]
pub dhcp6: bool,
/// DHCP overrides for IPv4 (parsed for schema compatibility, not acted on by afterburn)
#[serde(rename = "dhcp4-overrides")]
#[allow(dead_code)]
pub dhcp4_overrides: Option<DhcpOverrides>,
/// DHCP overrides for IPv6 (parsed for schema compatibility, not acted on by afterburn)
#[serde(rename = "dhcp6-overrides")]
#[allow(dead_code)]
pub dhcp6_overrides: Option<DhcpOverrides>,
/// Static IP addresses in CIDR notation
#[serde(default)]
pub addresses: Vec<String>,
Expand Down Expand Up @@ -258,32 +308,75 @@ impl NetworkConfigV1Entry {

// Handle static configuration
if subnet.subnet_type.contains("static") {
if subnet.address.is_none() {
return Err(anyhow::anyhow!(
"cannot convert static subnet to interface: missing address"
));
// Static subnet may have an IP address, or just routes/DNS configuration
if let Some(address) = &subnet.address {
if let Some(netmask) = &subnet.netmask {
let ip_addr = IpAddr::from_str(address)?;
// Try to parse netmask as IP address first, then as prefix length
let ip_network = if let Ok(netmask_addr) = IpAddr::from_str(netmask) {
IpNetwork::with_netmask(ip_addr, netmask_addr)?
} else if let Ok(prefix_len) = netmask.parse::<u8>() {
IpNetwork::new(ip_addr, prefix_len)?
} else {
return Err(anyhow::anyhow!(
"Invalid netmask format: {}. Expected IP address or prefix length.",
netmask
));
};
iface.ip_addresses.push(ip_network);
} else {
iface.ip_addresses.push(IpNetwork::from_str(address)?);
}
}
} else if subnet.subnet_type == "dhcp" || subnet.subnet_type == "dhcp4" {
iface.dhcp = match iface.dhcp {
Some(DhcpSetting::V6) => Some(DhcpSetting::Both),
_ => Some(DhcpSetting::V4),
};
} else if subnet.subnet_type == "dhcp6" {
iface.dhcp = match iface.dhcp {
Some(DhcpSetting::V4) => Some(DhcpSetting::Both),
_ => Some(DhcpSetting::V6),
};
} else {
warn!(
"subnet type \"{}\" not supported, ignoring",
subnet.subnet_type
);
}

if let Some(netmask) = &subnet.netmask {
let ip_addr = IpAddr::from_str(subnet.address.as_ref().unwrap())?;
// Try to parse netmask as IP address first, then as prefix length
let ip_network = if let Ok(netmask_addr) = IpAddr::from_str(netmask) {
IpNetwork::with_netmask(ip_addr, netmask_addr)?
} else if let Ok(prefix_len) = netmask.parse::<u8>() {
IpNetwork::new(ip_addr, prefix_len)?
// Handle routes from subnet
for route in &subnet.routes {
let gateway = IpAddr::from_str(&route.gateway)?;

// Parse destination: prefer `destination` (CIDR), fall back to `network`/`netmask`
let destination = if let Some(dest) = &route.destination {
IpNetwork::from_str(dest)?
} else if let Some(network) = &route.network {
let network_addr = IpAddr::from_str(network)?;
if let Some(netmask) = &route.netmask {
let netmask_addr = IpAddr::from_str(netmask)?;
IpNetwork::with_netmask(network_addr, netmask_addr)?
} else {
return Err(anyhow::anyhow!(
"Invalid netmask format: {}. Expected IP address or prefix length.",
netmask
"Route with 'network' field requires 'netmask'"
));
};
iface.ip_addresses.push(ip_network);
}
} else {
iface
.ip_addresses
.push(IpNetwork::from_str(subnet.address.as_ref().unwrap())?);
}
return Err(anyhow::anyhow!(
"Route must have either 'destination' or 'network' field"
));
};

iface.routes.push(NetworkRoute {
destination,
gateway,
});
}

// Handle legacy gateway field only if no explicit routes were defined,
// to avoid duplicate default routes
if subnet.routes.is_empty() {
if let Some(gateway) = &subnet.gateway {
let gateway = IpAddr::from_str(gateway)?;

Expand All @@ -297,27 +390,8 @@ impl NetworkConfigV1Entry {
destination,
gateway,
});
} else {
warn!("found subnet type \"static\" without gateway");
}
}

// Handle DHCP configuration
if subnet.subnet_type == "dhcp" || subnet.subnet_type == "dhcp4" {
iface.dhcp = match iface.dhcp {
Some(DhcpSetting::V6) => Some(DhcpSetting::Both),
_ => Some(DhcpSetting::V4),
};
}
if subnet.subnet_type == "dhcp6" {
iface.dhcp = match iface.dhcp {
Some(DhcpSetting::V4) => Some(DhcpSetting::Both),
_ => Some(DhcpSetting::V6),
};
}
if subnet.subnet_type == "ipv6_slaac" {
warn!("subnet type \"ipv6_slaac\" not supported, ignoring");
}
}

// Set MAC address if available
Expand Down
Loading
Loading