From 647746b48a0faddd6a639ec833078fe373a3ba51 Mon Sep 17 00:00:00 2001 From: RajMandaliya Date: Sat, 18 Jul 2026 10:58:26 -0700 Subject: [PATCH] feat: DHCP every network interface by default when no custom network-config is set cloud-init's own default behavior (no network-config at all) only DHCPs the first network interface it finds. For multi-NIC hosts using qcow-imager (or any tenant not providing a custom network: key), this leaves every other interface unconfigured. Adds DEFAULT_NETWORK_CONFIG, served whenever a tenant hasn't supplied their own network: key, that DHCPs every interface matching either the predictable (en*) or legacy (eth*) naming convention. Verified against real cloud-init: confirmed the exact constant string (byte-for-byte, written to a real seed directory) produces the expected netplan config covering both naming conventions. Addresses #3665. Signed-off-by: RajMandaliya --- crates/pxe/src/routes/cloud_init.rs | 48 +++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/pxe/src/routes/cloud_init.rs b/crates/pxe/src/routes/cloud_init.rs index 91f69a2841..49fbb359ff 100644 --- a/crates/pxe/src/routes/cloud_init.rs +++ b/crates/pxe/src/routes/cloud_init.rs @@ -327,18 +327,34 @@ fn extract_network_config(custom_cloud_init: &str) -> Option { serde_yaml::to_string(value.get("network")?).ok() } +/// Default network-config served when a tenant hasn't provided a custom +/// `network:` key in their cloud-init userdata. cloud-init's own default +/// behavior (no network-config at all) only DHCPs the first network +/// interface it finds; this instead DHCPs every matching interface, under +/// both the predictable ("en*") and legacy ("eth*") naming conventions, +/// so multi-NIC hosts come up with working networking on every port. +const DEFAULT_NETWORK_CONFIG: &str = "version: 2\nethernets:\n predictable-names:\n match:\n name: \"en*\"\n dhcp4: true\n dhcp6: true\n legacy-names:\n match:\n name: \"eth*\"\n dhcp4: true\n dhcp6: true\n"; + /// Serves NoCloud's `network-config` document for a tenant's assigned /// machine, extracted from any `network:` key present in their custom /// cloud-init userdata. Renders empty when no such key is present, which /// cloud-init treats as "no custom network config" and falls back to /// its default DHCP behavior. -pub async fn network_config(machine: Machine, state: State) -> impl IntoResponse { - let network_config_yaml = machine - .instructions - .custom_cloud_init - .as_deref() +fn resolve_network_config(custom_cloud_init: Option<&str>) -> String { + custom_cloud_init .and_then(extract_network_config) - .unwrap_or_default(); + .unwrap_or_else(|| DEFAULT_NETWORK_CONFIG.to_string()) +} + +/// Serves NoCloud's `network-config` document for a tenant's assigned +/// machine, extracted from any `network:` key present in their custom +/// cloud-init userdata. When no such key is present, serves +/// DEFAULT_NETWORK_CONFIG instead of an empty document, so hosts get +/// DHCP on every interface by default rather than cloud-init's own +/// first-interface-only behavior. +pub async fn network_config(machine: Machine, state: State) -> impl IntoResponse { + let network_config_yaml = + resolve_network_config(machine.instructions.custom_cloud_init.as_deref()); let mut template_data: HashMap = HashMap::new(); template_data.insert("network_config".to_string(), network_config_yaml); @@ -818,6 +834,26 @@ mod tests { } } + #[test] + fn resolve_network_config_uses_default_when_no_custom_key() { + let result = resolve_network_config(Some("#cloud-config\nwrite_files: []\n")); + assert_eq!(result, DEFAULT_NETWORK_CONFIG); + } + + #[test] + fn resolve_network_config_uses_custom_key_when_present() { + let custom = "#cloud-config\nnetwork:\n version: 2\n ethernets:\n eth0:\n addresses:\n - 10.10.10.50/24\n"; + let result = resolve_network_config(Some(custom)); + assert_ne!(result, DEFAULT_NETWORK_CONFIG); + assert!(result.contains("10.10.10.50")); + } + + #[test] + fn resolve_network_config_uses_default_when_no_custom_cloud_init_at_all() { + let result = resolve_network_config(None); + assert_eq!(result, DEFAULT_NETWORK_CONFIG); + } + /// A meta-data request with no metadata lands in the generic-error /// funnel, which serves the error template and moves the outcome /// counter.