Skip to content

kubevirt: Support static gateway and DNS with DHCP#1251

Open
qinqon wants to merge 1 commit intocoreos:mainfrom
qinqon:kubevirt-network-dynamic-address-static-gw-dns
Open

kubevirt: Support static gateway and DNS with DHCP#1251
qinqon wants to merge 1 commit intocoreos:mainfrom
qinqon:kubevirt-network-dynamic-address-static-gw-dns

Conversation

@qinqon
Copy link
Contributor

@qinqon qinqon commented Nov 26, 2025

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:

  • ConfigDrive: Always process routes on DHCP network types (ipv4_dhcp/ipv6_dhcp)
    per the OpenStack schema — no custom fields needed
  • NoCloud v1: Support both destination (CIDR) and network/netmask route formats;
    put routes/DNS directly on dhcp subnets; skip legacy gateway when explicit routes exist
  • NoCloud v2: Parse dhcp-overrides for schema compatibility; routes and nameservers
    always emitted when defined
  • Schema validation: Add test-time validation of all fixtures against upstream JSON
    schemas from OpenStack Ironic
    and cloud-init
  • Fixture fixes: Add required fields (ethernet_mac_address, network_id, name),
    move v2 nameservers to per-interface, remove non-standard fields

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 311 to 335
// 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,
});
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment on lines +272 to +648
#[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
);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

@qinqon qinqon force-pushed the kubevirt-network-dynamic-address-static-gw-dns branch 3 times, most recently from bcbf302 to cde6a31 Compare November 26, 2025 14:05
@qinqon qinqon changed the title kubevirt: Support static gateway and DNS with DHCP kubevirt: Support static gateway and DNS but dynamic address Nov 26, 2025
@qinqon qinqon force-pushed the kubevirt-network-dynamic-address-static-gw-dns branch from cde6a31 to bd09cb1 Compare November 26, 2025 16:18
@qinqon qinqon force-pushed the kubevirt-network-dynamic-address-static-gw-dns branch 2 times, most recently from 6ac7a1b to dc360a0 Compare December 16, 2025 09:58
Copy link
Contributor

@prestist prestist left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small question, otherwise it LGTM from my perspective.

@travier would you mind also taking a look as well :)

should_configure_static_dns = true;
}
}
"ipv6_dhcp" => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm so really accept_dhcp_option is IPv4 limited right? Is this a technical difference or intentional absence of functionality for ipv6

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@qinqon qinqon force-pushed the kubevirt-network-dynamic-address-static-gw-dns branch 2 times, most recently from 57ba54f to f7ac3bd Compare March 23, 2026 12:32
@qinqon qinqon changed the title kubevirt: Support static gateway and DNS but dynamic address kubevirt: Support static gateway and DNS with DHCP Mar 23, 2026
@qinqon qinqon force-pushed the kubevirt-network-dynamic-address-static-gw-dns branch 9 times, most recently from 50e60cb to baa7ff3 Compare March 23, 2026 13:41
@qinqon qinqon requested a review from prestist March 24, 2026 08:47
@qinqon qinqon force-pushed the kubevirt-network-dynamic-address-static-gw-dns branch 2 times, most recently from 5f2da26 to c9c14da Compare March 24, 2026 09:03
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>
@qinqon qinqon force-pushed the kubevirt-network-dynamic-address-static-gw-dns branch from c9c14da to a9a1da0 Compare March 24, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants