kubevirt: Support static gateway and DNS with DHCP#1251
kubevirt: Support static gateway and DNS with DHCP#1251prestist merged 2 commits intocoreos:mainfrom
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds support for configuring static gateways and DNS on DHCP-enabled interfaces in KubeVirt, which is a valuable feature for mixed network configurations. The implementation correctly handles this for both ConfigDrive and NoCloud data sources. The changes are well-tested with new test cases for various formats. My review focuses on improving code maintainability by addressing significant code duplication in both the network configuration logic and the newly added tests. By refactoring these areas, the code will be cleaner and easier to manage in the future.
src/providers/kubevirt/nocloud.rs
Outdated
| // Add static gateway for DHCP if provided | ||
| if let Some(gateway) = &subnet.gateway { | ||
| let gateway = IpAddr::from_str(gateway)?; | ||
| let destination = IpNetwork::from_str("0.0.0.0/0")?; | ||
| iface.routes.push(NetworkRoute { | ||
| destination, | ||
| gateway, | ||
| }); | ||
| } | ||
| } | ||
| if subnet.subnet_type == "dhcp6" { | ||
| iface.dhcp = match iface.dhcp { | ||
| Some(DhcpSetting::V4) => Some(DhcpSetting::Both), | ||
| _ => Some(DhcpSetting::V6), | ||
| }; | ||
| // Add static gateway for DHCP6 if provided | ||
| if let Some(gateway) = &subnet.gateway { | ||
| let gateway = IpAddr::from_str(gateway)?; | ||
| let destination = IpNetwork::from_str("::/0")?; | ||
| iface.routes.push(NetworkRoute { | ||
| destination, | ||
| gateway, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
The logic for adding a static gateway for DHCP is duplicated for IPv4 (lines 312-319) and IPv6 (lines 327-334). This code can be refactored to reduce redundancy and improve maintainability.
For example, you could extract the common logic into a closure or a private helper method. The only difference is the destination network string ("0.0.0.0/0" vs "::/0"), which can be passed as an argument or determined based on a flag.
This will make the code cleaner and easier to modify in the future.
src/providers/kubevirt/tests.rs
Outdated
| #[test] | ||
| fn test_dhcp_with_static_gateway_and_dns() { | ||
| let fixture_path = "./tests/fixtures/kubevirt/dhcp_static_gw_dns"; | ||
| let config = KubeVirtCloudConfig::try_new( | ||
| Path::new(fixture_path), | ||
| NetworkConfigurationFormat::ConfigDrive, | ||
| ) | ||
| .expect("cannot parse config"); | ||
|
|
||
| let interfaces = config.networks().expect("cannot get interfaces"); | ||
| assert_eq!( | ||
| interfaces.len(), | ||
| 1, | ||
| "Expected 1 interface, got {} interfaces: {:?}", | ||
| interfaces.len(), | ||
| interfaces | ||
| ); | ||
|
|
||
| let eth0 = &interfaces[0]; | ||
| assert_eq!( | ||
| eth0.name, | ||
| Some("eth0".to_string()), | ||
| "Expected eth0.name to be {:?}, got {:?}", | ||
| Some("eth0".to_string()), | ||
| eth0.name | ||
| ); | ||
| assert_eq!( | ||
| eth0.dhcp, | ||
| Some(DhcpSetting::Both), | ||
| "Expected eth0.dhcp to be {:?}, got {:?}", | ||
| Some(DhcpSetting::Both), | ||
| eth0.dhcp | ||
| ); | ||
| assert_eq!( | ||
| eth0.ip_addresses.len(), | ||
| 0, | ||
| "Expected eth0 to have 0 static IP addresses (using DHCP), got {} addresses: {:?}", | ||
| eth0.ip_addresses.len(), | ||
| eth0.ip_addresses | ||
| ); | ||
| assert_eq!( | ||
| eth0.routes.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 static routes (IPv4 and IPv6 default gateways), got {} routes: {:?}", | ||
| eth0.routes.len(), | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("192.168.1.1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 192.168.1.1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("2001:db8::1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 2001:db8::1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert_eq!( | ||
| eth0.nameservers.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 nameservers, got {} nameservers: {:?}", | ||
| eth0.nameservers.len(), | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.8.8").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.8.8, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.4.4").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.4.4, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
|
|
||
| // Test the kernel arguments generation | ||
| let kargs = config.rd_network_kargs().unwrap().unwrap(); | ||
| let kargs_parts: Vec<&str> = kargs.split_whitespace().collect(); | ||
|
|
||
| // Expected parts: | ||
| // 1. ip=eth0:dhcp,dhcp6 | ||
| // 2. rd.route=0.0.0.0/0:192.168.1.1 | ||
| // 3. rd.route=::/0:2001:db8::1 | ||
| // 4. nameserver=8.8.8.8 | ||
| // 5. nameserver=8.8.4.4 | ||
| assert_eq!( | ||
| kargs_parts.len(), | ||
| 5, | ||
| "Expected kargs to have 5 parts (1 dhcp + 2 routes + 2 nameservers), got {} parts: {:?}", | ||
| kargs_parts.len(), | ||
| kargs_parts | ||
| ); | ||
|
|
||
| assert!( | ||
| kargs.contains("ip=eth0:dhcp,dhcp6"), | ||
| "Expected kargs to contain 'ip=eth0:dhcp,dhcp6', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=0.0.0.0/0:192.168.1.1"), | ||
| "Expected kargs to contain 'rd.route=0.0.0.0/0:192.168.1.1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=::/0:2001:db8::1"), | ||
| "Expected kargs to contain 'rd.route=::/0:2001:db8::1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.8.8"), | ||
| "Expected kargs to contain 'nameserver=8.8.8.8', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.4.4"), | ||
| "Expected kargs to contain 'nameserver=8.8.4.4', but got: {:?}", | ||
| kargs | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_dhcp_with_static_gateway_and_dns_nocloud_v1() { | ||
| let fixture_path = "./tests/fixtures/kubevirt/dhcp_static_gw_dns_nocloud_v1"; | ||
| let config = KubeVirtCloudConfig::try_new( | ||
| Path::new(fixture_path), | ||
| NetworkConfigurationFormat::NoCloud, | ||
| ) | ||
| .expect("cannot parse config"); | ||
|
|
||
| let interfaces = config.networks().expect("cannot get interfaces"); | ||
| assert_eq!( | ||
| interfaces.len(), | ||
| 1, | ||
| "Expected 1 interface, got {} interfaces: {:?}", | ||
| interfaces.len(), | ||
| interfaces | ||
| ); | ||
|
|
||
| let eth0 = &interfaces[0]; | ||
| assert_eq!( | ||
| eth0.name, | ||
| Some("eth0".to_string()), | ||
| "Expected eth0.name to be {:?}, got {:?}", | ||
| Some("eth0".to_string()), | ||
| eth0.name | ||
| ); | ||
| assert_eq!( | ||
| eth0.dhcp, | ||
| Some(DhcpSetting::Both), | ||
| "Expected eth0.dhcp to be {:?}, got {:?}", | ||
| Some(DhcpSetting::Both), | ||
| eth0.dhcp | ||
| ); | ||
| assert_eq!( | ||
| eth0.ip_addresses.len(), | ||
| 0, | ||
| "Expected eth0 to have 0 static IP addresses (using DHCP), got {} addresses: {:?}", | ||
| eth0.ip_addresses.len(), | ||
| eth0.ip_addresses | ||
| ); | ||
| assert_eq!( | ||
| eth0.routes.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 static routes (IPv4 and IPv6 default gateways), got {} routes: {:?}", | ||
| eth0.routes.len(), | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("192.168.1.1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 192.168.1.1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("2001:db8::1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 2001:db8::1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert_eq!( | ||
| eth0.nameservers.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 nameservers, got {} nameservers: {:?}", | ||
| eth0.nameservers.len(), | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.8.8").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.8.8, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.4.4").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.4.4, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
|
|
||
| // Test the kernel arguments generation | ||
| let kargs = config.rd_network_kargs().unwrap().unwrap(); | ||
| let kargs_parts: Vec<&str> = kargs.split_whitespace().collect(); | ||
|
|
||
| // Expected parts: | ||
| // 1. ip=eth0:dhcp,dhcp6 | ||
| // 2. rd.route=0.0.0.0/0:192.168.1.1 | ||
| // 3. rd.route=::/0:2001:db8::1 | ||
| // 4. nameserver=8.8.8.8 | ||
| // 5. nameserver=8.8.4.4 | ||
| assert_eq!( | ||
| kargs_parts.len(), | ||
| 5, | ||
| "Expected kargs to have 5 parts (1 dhcp + 2 routes + 2 nameservers), got {} parts: {:?}", | ||
| kargs_parts.len(), | ||
| kargs_parts | ||
| ); | ||
|
|
||
| assert!( | ||
| kargs.contains("ip=eth0:dhcp,dhcp6"), | ||
| "Expected kargs to contain 'ip=eth0:dhcp,dhcp6', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=0.0.0.0/0:192.168.1.1"), | ||
| "Expected kargs to contain 'rd.route=0.0.0.0/0:192.168.1.1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=::/0:2001:db8::1"), | ||
| "Expected kargs to contain 'rd.route=::/0:2001:db8::1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.8.8"), | ||
| "Expected kargs to contain 'nameserver=8.8.8.8', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.4.4"), | ||
| "Expected kargs to contain 'nameserver=8.8.4.4', but got: {:?}", | ||
| kargs | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_dhcp_with_static_gateway_and_dns_nocloud_v2() { | ||
| let fixture_path = "./tests/fixtures/kubevirt/dhcp_static_gw_dns_nocloud_v2"; | ||
| let config = KubeVirtCloudConfig::try_new( | ||
| Path::new(fixture_path), | ||
| NetworkConfigurationFormat::NoCloud, | ||
| ) | ||
| .expect("cannot parse config"); | ||
|
|
||
| let interfaces = config.networks().expect("cannot get interfaces"); | ||
| assert_eq!( | ||
| interfaces.len(), | ||
| 1, | ||
| "Expected 1 interface, got {} interfaces: {:?}", | ||
| interfaces.len(), | ||
| interfaces | ||
| ); | ||
|
|
||
| let eth0 = &interfaces[0]; | ||
| assert_eq!( | ||
| eth0.name, | ||
| Some("eth0".to_string()), | ||
| "Expected eth0.name to be {:?}, got {:?}", | ||
| Some("eth0".to_string()), | ||
| eth0.name | ||
| ); | ||
| assert_eq!( | ||
| eth0.dhcp, | ||
| Some(DhcpSetting::Both), | ||
| "Expected eth0.dhcp to be {:?}, got {:?}", | ||
| Some(DhcpSetting::Both), | ||
| eth0.dhcp | ||
| ); | ||
| assert_eq!( | ||
| eth0.ip_addresses.len(), | ||
| 0, | ||
| "Expected eth0 to have 0 static IP addresses (using DHCP), got {} addresses: {:?}", | ||
| eth0.ip_addresses.len(), | ||
| eth0.ip_addresses | ||
| ); | ||
| assert_eq!( | ||
| eth0.routes.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 static routes (IPv4 and IPv6 default gateways), got {} routes: {:?}", | ||
| eth0.routes.len(), | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("192.168.1.1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 192.168.1.1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("2001:db8::1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 2001:db8::1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert_eq!( | ||
| eth0.nameservers.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 nameservers, got {} nameservers: {:?}", | ||
| eth0.nameservers.len(), | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.8.8").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.8.8, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.4.4").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.4.4, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
|
|
||
| // Test the kernel arguments generation | ||
| let kargs = config.rd_network_kargs().unwrap().unwrap(); | ||
| let kargs_parts: Vec<&str> = kargs.split_whitespace().collect(); | ||
|
|
||
| // Expected parts: | ||
| // 1. ip=eth0:dhcp,dhcp6 | ||
| // 2. rd.route=0.0.0.0/0:192.168.1.1 | ||
| // 3. rd.route=::/0:2001:db8::1 | ||
| // 4. nameserver=8.8.8.8 | ||
| // 5. nameserver=8.8.4.4 | ||
| assert_eq!( | ||
| kargs_parts.len(), | ||
| 5, | ||
| "Expected kargs to have 5 parts (1 dhcp + 2 routes + 2 nameservers), got {} parts: {:?}", | ||
| kargs_parts.len(), | ||
| kargs_parts | ||
| ); | ||
|
|
||
| assert!( | ||
| kargs.contains("ip=eth0:dhcp,dhcp6"), | ||
| "Expected kargs to contain 'ip=eth0:dhcp,dhcp6', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=0.0.0.0/0:192.168.1.1"), | ||
| "Expected kargs to contain 'rd.route=0.0.0.0/0:192.168.1.1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=::/0:2001:db8::1"), | ||
| "Expected kargs to contain 'rd.route=::/0:2001:db8::1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.8.8"), | ||
| "Expected kargs to contain 'nameserver=8.8.8.8', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.4.4"), | ||
| "Expected kargs to contain 'nameserver=8.8.4.4', but got: {:?}", | ||
| kargs | ||
| ); | ||
| } |
There was a problem hiding this comment.
These three new test functions (test_dhcp_with_static_gateway_and_dns, test_dhcp_with_static_gateway_and_dns_nocloud_v1, and test_dhcp_with_static_gateway_and_dns_nocloud_v2) are almost identical and contain a lot of duplicated assertion logic.
To improve maintainability and reduce code duplication, consider creating a single helper function that takes a &KubeVirtCloudConfig and performs all the common assertions. Each test would then just be responsible for setting up its specific configuration and calling this shared helper.
Example of a shared assertion function signature:
fn assert_dhcp_with_static_gw_and_dns(config: &KubeVirtCloudConfig) {
// All the common assertion logic goes here
}This would make the test suite much cleaner and easier to manage.
bcbf302 to
cde6a31
Compare
cde6a31 to
bd09cb1
Compare
6ac7a1b to
dc360a0
Compare
| should_configure_static_dns = true; | ||
| } | ||
| } | ||
| "ipv6_dhcp" => { |
There was a problem hiding this comment.
Hmm so really accept_dhcp_option is IPv4 limited right? Is this a technical difference or intentional absence of functionality for ipv6
There was a problem hiding this comment.
Good catch — accept_dhcp_option was actually an invented field that doesn't exist in the OpenStack network_data.json schema. I've removed it in the latest force-push.
The OpenStack schema already allows routes on ipv4_dhcp/ipv6_dhcp network types, so the presence of routes on a DHCP network entry is sufficient signal to configure them statically — no custom field needed.
I also added test-time schema validation (using the upstream JSON schemas from Ironic and cloud-init) to catch this kind of deviation going forward. The fixtures have been fixed to comply with the upstream schemas as well.
57ba54f to
f7ac3bd
Compare
50e60cb to
baa7ff3
Compare
a9a1da0 to
b45888b
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request adds support for static gateway and DNS configuration on DHCP-enabled interfaces in KubeVirt. The changes are well-implemented across ConfigDrive and NoCloud providers, and include new schema validation for test fixtures. I have two suggestions for improvement: one to enhance route parsing consistency in nocloud.rs, and another to refactor duplicated test code in tests.rs for better maintainability.
| 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); | ||
| } |
There was a problem hiding this comment.
The route parsing logic for netmask when used with network only attempts to parse it as an IP address. For consistency with other parts of the codebase (e.g., static IP netmask parsing in this file, and route parsing in configdrive.rs), it should also support parsing the netmask as a prefix length.
| 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); | |
| } | |
| if let Some(netmask) = &route.netmask { | |
| if let Ok(netmask_addr) = IpAddr::from_str(netmask) { | |
| IpNetwork::with_netmask(network_addr, netmask_addr)? | |
| } else if let Ok(prefix_len) = netmask.parse::<u8>() { | |
| IpNetwork::new(network_addr, prefix_len)? | |
| } else { | |
| return Err(anyhow::anyhow!( | |
| "Invalid netmask format for route: {}. Expected IP address or prefix length.", | |
| netmask | |
| )); | |
| } | |
| } else { | |
| return Err(anyhow::anyhow!( | |
| "Route with 'network' field requires 'netmask'" | |
| )); | |
| } |
src/providers/kubevirt/tests.rs
Outdated
| #[test] | ||
| fn test_dhcp_with_static_gateway_and_dns() { | ||
| let fixture_path = "./tests/fixtures/kubevirt/dhcp_static_gw_dns"; | ||
| let config = KubeVirtCloudConfig::try_new( | ||
| Path::new(fixture_path), | ||
| NetworkConfigurationFormat::ConfigDrive, | ||
| ) | ||
| .expect("cannot parse config"); | ||
|
|
||
| let interfaces = config.networks().expect("cannot get interfaces"); | ||
| assert_eq!( | ||
| interfaces.len(), | ||
| 1, | ||
| "Expected 1 interface, got {} interfaces: {:?}", | ||
| interfaces.len(), | ||
| interfaces | ||
| ); | ||
|
|
||
| let eth0 = &interfaces[0]; | ||
| assert_eq!( | ||
| eth0.name, | ||
| Some("eth0".to_string()), | ||
| "Expected eth0.name to be {:?}, got {:?}", | ||
| Some("eth0".to_string()), | ||
| eth0.name | ||
| ); | ||
| assert_eq!( | ||
| eth0.dhcp, | ||
| Some(DhcpSetting::Both), | ||
| "Expected eth0.dhcp to be {:?}, got {:?}", | ||
| Some(DhcpSetting::Both), | ||
| eth0.dhcp | ||
| ); | ||
| assert_eq!( | ||
| eth0.ip_addresses.len(), | ||
| 0, | ||
| "Expected eth0 to have 0 static IP addresses (using DHCP), got {} addresses: {:?}", | ||
| eth0.ip_addresses.len(), | ||
| eth0.ip_addresses | ||
| ); | ||
| assert_eq!( | ||
| eth0.routes.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 static routes (IPv4 and IPv6 default gateways), got {} routes: {:?}", | ||
| eth0.routes.len(), | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("192.168.1.1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 192.168.1.1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("2001:db8::1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 2001:db8::1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert_eq!( | ||
| eth0.nameservers.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 nameservers, got {} nameservers: {:?}", | ||
| eth0.nameservers.len(), | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.8.8").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.8.8, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.4.4").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.4.4, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
|
|
||
| // Test the kernel arguments generation | ||
| let kargs = config.rd_network_kargs().unwrap().unwrap(); | ||
| let kargs_parts: Vec<&str> = kargs.split_whitespace().collect(); | ||
|
|
||
| // Expected parts: | ||
| // 1. ip=eth0:dhcp,dhcp6 | ||
| // 2. rd.route=0.0.0.0/0:192.168.1.1 | ||
| // 3. rd.route=::/0:2001:db8::1 | ||
| // 4. nameserver=8.8.8.8 | ||
| // 5. nameserver=8.8.4.4 | ||
| assert_eq!( | ||
| kargs_parts.len(), | ||
| 5, | ||
| "Expected kargs to have 5 parts (1 dhcp + 2 routes + 2 nameservers), got {} parts: {:?}", | ||
| kargs_parts.len(), | ||
| kargs_parts | ||
| ); | ||
|
|
||
| assert!( | ||
| kargs.contains("ip=eth0:dhcp,dhcp6"), | ||
| "Expected kargs to contain 'ip=eth0:dhcp,dhcp6', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=0.0.0.0/0:192.168.1.1"), | ||
| "Expected kargs to contain 'rd.route=0.0.0.0/0:192.168.1.1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=::/0:2001:db8::1"), | ||
| "Expected kargs to contain 'rd.route=::/0:2001:db8::1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.8.8"), | ||
| "Expected kargs to contain 'nameserver=8.8.8.8', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.4.4"), | ||
| "Expected kargs to contain 'nameserver=8.8.4.4', but got: {:?}", | ||
| kargs | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_dhcp_with_static_gateway_and_dns_nocloud_v1() { | ||
| let fixture_path = "./tests/fixtures/kubevirt/dhcp_static_gw_dns_nocloud_v1"; | ||
| let config = | ||
| KubeVirtCloudConfig::try_new(Path::new(fixture_path), NetworkConfigurationFormat::NoCloud) | ||
| .expect("cannot parse config"); | ||
|
|
||
| let interfaces = config.networks().expect("cannot get interfaces"); | ||
| assert_eq!( | ||
| interfaces.len(), | ||
| 1, | ||
| "Expected 1 interface, got {} interfaces: {:?}", | ||
| interfaces.len(), | ||
| interfaces | ||
| ); | ||
|
|
||
| let eth0 = &interfaces[0]; | ||
| assert_eq!( | ||
| eth0.name, | ||
| Some("eth0".to_string()), | ||
| "Expected eth0.name to be {:?}, got {:?}", | ||
| Some("eth0".to_string()), | ||
| eth0.name | ||
| ); | ||
| assert_eq!( | ||
| eth0.dhcp, | ||
| Some(DhcpSetting::Both), | ||
| "Expected eth0.dhcp to be {:?}, got {:?}", | ||
| Some(DhcpSetting::Both), | ||
| eth0.dhcp | ||
| ); | ||
| assert_eq!( | ||
| eth0.ip_addresses.len(), | ||
| 0, | ||
| "Expected eth0 to have 0 static IP addresses (using DHCP), got {} addresses: {:?}", | ||
| eth0.ip_addresses.len(), | ||
| eth0.ip_addresses | ||
| ); | ||
| assert_eq!( | ||
| eth0.routes.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 static routes (IPv4 and IPv6 default gateways), got {} routes: {:?}", | ||
| eth0.routes.len(), | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("192.168.1.1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 192.168.1.1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("2001:db8::1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 2001:db8::1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert_eq!( | ||
| eth0.nameservers.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 nameservers, got {} nameservers: {:?}", | ||
| eth0.nameservers.len(), | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.8.8").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.8.8, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.4.4").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.4.4, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
|
|
||
| // Test the kernel arguments generation | ||
| let kargs = config.rd_network_kargs().unwrap().unwrap(); | ||
| let kargs_parts: Vec<&str> = kargs.split_whitespace().collect(); | ||
|
|
||
| // Expected parts: | ||
| // 1. ip=eth0:dhcp,dhcp6 | ||
| // 2. rd.route=0.0.0.0/0:192.168.1.1 | ||
| // 3. rd.route=::/0:2001:db8::1 | ||
| // 4. nameserver=8.8.8.8 | ||
| // 5. nameserver=8.8.4.4 | ||
| assert_eq!( | ||
| kargs_parts.len(), | ||
| 5, | ||
| "Expected kargs to have 5 parts (1 dhcp + 2 routes + 2 nameservers), got {} parts: {:?}", | ||
| kargs_parts.len(), | ||
| kargs_parts | ||
| ); | ||
|
|
||
| assert!( | ||
| kargs.contains("ip=eth0:dhcp,dhcp6"), | ||
| "Expected kargs to contain 'ip=eth0:dhcp,dhcp6', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=0.0.0.0/0:192.168.1.1"), | ||
| "Expected kargs to contain 'rd.route=0.0.0.0/0:192.168.1.1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=::/0:2001:db8::1"), | ||
| "Expected kargs to contain 'rd.route=::/0:2001:db8::1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.8.8"), | ||
| "Expected kargs to contain 'nameserver=8.8.8.8', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.4.4"), | ||
| "Expected kargs to contain 'nameserver=8.8.4.4', but got: {:?}", | ||
| kargs | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_dhcp_with_static_gateway_and_dns_nocloud_v2() { | ||
| let fixture_path = "./tests/fixtures/kubevirt/dhcp_static_gw_dns_nocloud_v2"; | ||
| let config = | ||
| KubeVirtCloudConfig::try_new(Path::new(fixture_path), NetworkConfigurationFormat::NoCloud) | ||
| .expect("cannot parse config"); | ||
|
|
||
| let interfaces = config.networks().expect("cannot get interfaces"); | ||
| assert_eq!( | ||
| interfaces.len(), | ||
| 1, | ||
| "Expected 1 interface, got {} interfaces: {:?}", | ||
| interfaces.len(), | ||
| interfaces | ||
| ); | ||
|
|
||
| let eth0 = &interfaces[0]; | ||
| assert_eq!( | ||
| eth0.name, | ||
| Some("eth0".to_string()), | ||
| "Expected eth0.name to be {:?}, got {:?}", | ||
| Some("eth0".to_string()), | ||
| eth0.name | ||
| ); | ||
| assert_eq!( | ||
| eth0.dhcp, | ||
| Some(DhcpSetting::Both), | ||
| "Expected eth0.dhcp to be {:?}, got {:?}", | ||
| Some(DhcpSetting::Both), | ||
| eth0.dhcp | ||
| ); | ||
| assert_eq!( | ||
| eth0.ip_addresses.len(), | ||
| 0, | ||
| "Expected eth0 to have 0 static IP addresses (using DHCP), got {} addresses: {:?}", | ||
| eth0.ip_addresses.len(), | ||
| eth0.ip_addresses | ||
| ); | ||
| assert_eq!( | ||
| eth0.routes.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 static routes (IPv4 and IPv6 default gateways), got {} routes: {:?}", | ||
| eth0.routes.len(), | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("192.168.1.1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 192.168.1.1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert!( | ||
| eth0.routes | ||
| .iter() | ||
| .any(|r| r.gateway == IpAddr::from_str("2001:db8::1").unwrap()), | ||
| "Expected eth0.routes to contain gateway 2001:db8::1, but got routes: {:?}", | ||
| eth0.routes | ||
| ); | ||
| assert_eq!( | ||
| eth0.nameservers.len(), | ||
| 2, | ||
| "Expected eth0 to have 2 nameservers, got {} nameservers: {:?}", | ||
| eth0.nameservers.len(), | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.8.8").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.8.8, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
| assert!( | ||
| eth0.nameservers | ||
| .contains(&IpAddr::from_str("8.8.4.4").unwrap()), | ||
| "Expected eth0.nameservers to contain 8.8.4.4, but got: {:?}", | ||
| eth0.nameservers | ||
| ); | ||
|
|
||
| // Test the kernel arguments generation | ||
| let kargs = config.rd_network_kargs().unwrap().unwrap(); | ||
| let kargs_parts: Vec<&str> = kargs.split_whitespace().collect(); | ||
|
|
||
| // Expected parts: | ||
| // 1. ip=eth0:dhcp,dhcp6 | ||
| // 2. rd.route=0.0.0.0/0:192.168.1.1 | ||
| // 3. rd.route=::/0:2001:db8::1 | ||
| // 4. nameserver=8.8.8.8 | ||
| // 5. nameserver=8.8.4.4 | ||
| assert_eq!( | ||
| kargs_parts.len(), | ||
| 5, | ||
| "Expected kargs to have 5 parts (1 dhcp + 2 routes + 2 nameservers), got {} parts: {:?}", | ||
| kargs_parts.len(), | ||
| kargs_parts | ||
| ); | ||
|
|
||
| assert!( | ||
| kargs.contains("ip=eth0:dhcp,dhcp6"), | ||
| "Expected kargs to contain 'ip=eth0:dhcp,dhcp6', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=0.0.0.0/0:192.168.1.1"), | ||
| "Expected kargs to contain 'rd.route=0.0.0.0/0:192.168.1.1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("rd.route=::/0:2001:db8::1"), | ||
| "Expected kargs to contain 'rd.route=::/0:2001:db8::1', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.8.8"), | ||
| "Expected kargs to contain 'nameserver=8.8.8.8', but got: {:?}", | ||
| kargs | ||
| ); | ||
| assert!( | ||
| kargs.contains("nameserver=8.8.4.4"), | ||
| "Expected kargs to contain 'nameserver=8.8.4.4', but got: {:?}", | ||
| kargs | ||
| ); | ||
| } |
There was a problem hiding this comment.
These three tests (test_dhcp_with_static_gateway_and_dns, test_dhcp_with_static_gateway_and_dns_nocloud_v1, test_dhcp_with_static_gateway_and_dns_nocloud_v2) contain a large amount of duplicated code. To improve maintainability, consider extracting the common test logic into a helper function. This function could take the fixture path and network configuration format as parameters. For example:
fn run_dhcp_static_gw_dns_test(fixture_path: &str, format: NetworkConfigurationFormat) {
// ... common test logic ...
}
#[test]
fn test_dhcp_with_static_gateway_and_dns() {
run_dhcp_static_gw_dns_test(
"./tests/fixtures/kubevirt/dhcp_static_gw_dns",
NetworkConfigurationFormat::ConfigDrive,
);
}
// ... other tests ...b45888b to
fffce80
Compare
|
Can you split the IBM parts into another PR or at least another commit? Thanks |
Cargo.lock
Outdated
| "maplit", | ||
| "mockito", | ||
| "nix 0.29.0", | ||
| "nix 0.30.1", |
There was a problem hiding this comment.
Can you split this into another commit? Is this needed for this PR?
There was a problem hiding this comment.
Not needed, this is from and old version where I used the rust crate to validate fixtures, but that got simplified by just using a python strict that validate fixtures before using them on tests at the Makefile.
There was a problem hiding this comment.
Not needed, this is from and old version where I used the rust crate to validate fixtures, but that got simplified by just using a python strict that validate fixtures before using them on tests at the Makefile.
@travier I will put the IBM part at different commit, the important part though if we don't change production code related to that is just the test itself. |
fffce80 to
2997660
Compare
|
|
||
| // Handle legacy gateway field only if no explicit routes were defined, | ||
| // to avoid duplicate default routes | ||
| if subnet.routes.is_empty() { |
There was a problem hiding this comment.
The v1 parser guarded against duplicates, when both gateway and explicit routs are set. But in the v2 I don't think we are? I don't think that this could cause issues in runtime but might make sense to fix?
There was a problem hiding this comment.
The v1 parser guarded against duplicates, when both gateway and explicit routs are set. But in the v2 I don't think we are? I don't think that this could cause issues in runtime but might make sense to fix?
Right, in fact we are missing a pair of fixtures to test that
8976177 to
783c67a
Compare
|
I have also found that for ipv6 dracut mandates brackets around IPs/routes since ":" is already the dracut separator. |
783c67a to
795b327
Compare
|
prestist
left a comment
There was a problem hiding this comment.
From a code perspective this lgtm. Thank you for working on this, have you had a chance to test the latest changes in kubevirt?
I have start three VMs with the three formats and I get on the three of them for dynamic address and static gateway and dns I have found that this is invalid the dualstack |
795b327 to
3121528
Compare
|
@prestist pushed the fix, I will ping you after retesting with real VMs on kubevirt. |
So looks like there is a bug at nm-initrd-generator and doing two eth0 configs one override the other so ipv4 get disabled for network manager generator we should fallback to ip=eth0:dhcp,dhcp6 puff... clearly unit test is not enoug for all this :-/ |
Allow DHCP interfaces to have statically configured gateway and DNS by processing routes on DHCP network types (ipv4_dhcp/ipv6_dhcp) per the OpenStack network_data.json schema. Changes: - configdrive: Remove invented accept_dhcp_option field; always process routes when present on any network type - nocloud v1: Support both destination (CIDR) and network/netmask route formats; skip legacy gateway when explicit routes exist - nocloud v2: Parse dhcp-overrides for schema compatibility - Fix fixtures to comply with upstream schemas (add required fields, move nameservers to per-interface in v2) - Add test-time schema validation against upstream JSON schemas from OpenStack Ironic and cloud-init Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Enrique Llorente <ellorent@redhat.com>
Add required network_id field to ibmcloud-classic network_data.json fixture to comply with the OpenStack network_data.json schema. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Enrique Llorente <ellorent@redhat.com>
3121528 to
4ff27f5
Compare
|
@prestist looks like dracut do not support IPv6 DHCPv6 Stateful for NetworkManager so we cannot implement full IPv6 support is that ok ? This is the wrong thing at NetworkManager For the NM patch, you'd need nm-initrd-generator to:
Maybe we can merge this since ipv4 is working fine and continue with ipv6 support later on, for the use case we need ipv4 is enough. |
|
In am going get back to this soon sorry, been jumping around too much. |
|
Yes, this should be fine to merge as-is. The IPv6 DHCPv6 stateful limitation is an upstream dracut/NetworkManager issue, not something we can fix in afterburn. We can address full IPv6 DHCPv6 stateful support in a future PR once the upstream nm-initrd-generator adds support for ipv6.method=dhcp. The current implementation is correct and doesn't block that work. LGTM to merge. Thank you again for the thorough testing and documentation of the limitation! |
Support configuring static gateway routes and DNS servers on DHCP-enabled
interfaces in KubeVirt. This allows mixed configurations where IP addresses
are obtained via DHCP but routing and DNS are statically configured.
Changes:
ipv4_dhcp/ipv6_dhcp)per the OpenStack schema — no custom fields needed
destination(CIDR) andnetwork/netmaskroute formats;put routes/DNS directly on dhcp subnets; skip legacy
gatewaywhen explicit routes existdhcp-overridesfor schema compatibility; routes and nameserversalways emitted when defined
schemas from OpenStack Ironic
and cloud-init
ethernet_mac_address,network_id,name),move v2 nameservers to per-interface, remove non-standard fields