From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 987661FF0AA for ; Fri, 21 Aug 2026 16:05:09 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 050A721717; Fri, 21 Aug 2026 16:04:25 +0200 (CEST) From: Gabriel Goller To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-perl-rs 08/15] pve-rs: fabrics: include VRF routes in aggregate status Date: Fri, 21 Aug 2026 16:03:52 +0200 Message-ID: <20260821140404.322081-9-g.goller@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260821140404.322081-1-g.goller@proxmox.com> References: <20260821140404.322081-1-g.goller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1787321023250 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.752 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: QBMALWZOT7ROM4QTQQVDXY5SHTZT5MM7 X-Message-ID-Hash: QBMALWZOT7ROM4QTQQVDXY5SHTZT5MM7 X-MailFrom: g.goller@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Collect OSPF and BGP routes from every VRF used by a fabric on the local node, in addition to routes from the default routing table. Run the related commands in one vtysh process and merge their JSON output, including entries which share the same prefix. Match routes by VRF when deciding whether a fabric has active routes. This prevents routes from another routing table from affecting its status. Add a parser test for duplicate prefixes across VRFs. Signed-off-by: Gabriel Goller --- pve-rs/src/bindings/sdn/fabrics.rs | 169 +++++++++++++++++------------ pve-rs/src/sdn/status.rs | 19 ++-- 2 files changed, 113 insertions(+), 75 deletions(-) diff --git a/pve-rs/src/bindings/sdn/fabrics.rs b/pve-rs/src/bindings/sdn/fabrics.rs index 0fb1ccec9b7c..b714b83951a8 100644 --- a/pve-rs/src/bindings/sdn/fabrics.rs +++ b/pve-rs/src/bindings/sdn/fabrics.rs @@ -5,7 +5,7 @@ pub mod pve_rs_sdn_fabrics { //! This provides the configuration for the SDN fabrics, as well as helper methods for reading //! / writing the configuration, as well as for generating ifupdown2 and FRR configuration. - use std::collections::{BTreeMap, HashMap, HashSet}; + use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::Write; use std::net::IpAddr; use std::ops::Deref; @@ -1109,6 +1109,68 @@ pub mod pve_rs_sdn_fabrics { } } + fn extend_routes(destination: &mut proxmox_frr::de::Routes, source: proxmox_frr::de::Routes) { + for (prefix, routes) in source.0 { + destination.0.entry(prefix).or_default().extend(routes); + } + } + + /// Query and merge routes from multiple FRR commands in a single `vtysh` process. + fn query_routes( + commands: impl IntoIterator, + protocol: &str, + ) -> Result { + let mut command = Command::new("vtysh"); + command.env("VTYSH_HISTFILE", "/dev/null"); + for query in commands { + command.args(["-c", &query]); + } + + let output = command.output()?; + if !output.status.success() { + anyhow::bail!( + "error querying {protocol} routes: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + parse_routes(&output.stdout, protocol) + } + + fn parse_routes(raw: &[u8], protocol: &str) -> Result { + let mut routes = proxmox_frr::de::Routes::default(); + let route_stream = + serde_json::Deserializer::from_slice(raw).into_iter::(); + for queried_routes in route_stream { + extend_routes( + &mut routes, + queried_routes.with_context(|| format!("error parsing {protocol} routes"))?, + ); + } + + Ok(routes) + } + + #[cfg(test)] + mod tests { + use super::parse_routes; + + #[test] + fn parses_batched_routes_with_duplicate_prefixes() { + let raw = br#" + {"10.0.0.0/24":[{"nexthops":[],"metric":10,"protocol":"bgp","vrfName":"vrf_a"}]} + {"10.0.0.0/24":[{"nexthops":[],"metric":10,"protocol":"bgp","vrfName":"vrf_b"}]} + "#; + + let routes = parse_routes(raw, "BGP").expect("batched routes should parse"); + let routes = routes.0.values().next().expect("route should exist"); + + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].vrf_name, "vrf_a"); + assert_eq!(routes[1].vrf_name, "vrf_b"); + } + } + /// Return the status of all fabrics on this node. /// /// Go through all fabrics in the config, then filter out the ones that exist on this node. @@ -1124,76 +1186,47 @@ pub mod pve_rs_sdn_fabrics { return Ok(HashMap::new()); }; - let openfabric_ipv4_routes_string = String::from_utf8( - Command::new("sh") - .args(["-c", "vtysh -c 'show ip route openfabric json'"]) - .output()? - .stdout, - )?; - - let openfabric_ipv6_routes_string = String::from_utf8( - Command::new("sh") - .args(["-c", "vtysh -c 'show ipv6 route openfabric json'"]) - .output()? - .stdout, - )?; - - let ospf_routes_string = String::from_utf8( - Command::new("sh") - .args(["-c", "vtysh -c 'show ip route ospf json'"]) - .output()? - .stdout, - )?; - - let mut openfabric_routes: proxmox_frr::de::Routes = - if openfabric_ipv4_routes_string.is_empty() { - proxmox_frr::de::Routes::default() - } else { - serde_json::from_str(&openfabric_ipv4_routes_string) - .with_context(|| "error parsing openfabric ipv4 routes")? - }; - if !openfabric_ipv6_routes_string.is_empty() { - let openfabric_ipv6_routes: proxmox_frr::de::Routes = - serde_json::from_str(&openfabric_ipv6_routes_string) - .with_context(|| "error parsing openfabric ipv6 routes")?; - openfabric_routes.0.extend(openfabric_ipv6_routes.0); - } - - let ospf_routes: proxmox_frr::de::Routes = if ospf_routes_string.is_empty() { - proxmox_frr::de::Routes::default() - } else { - serde_json::from_str(&ospf_routes_string) - .with_context(|| "error parsing ospf routes")? - }; - - let bgp_ipv4_routes_string = String::from_utf8( - Command::new("sh") - .env("VTYSH_HISTFILE", "/dev/null") - .args(["-c", "vtysh -c 'show ip route bgp json'"]) - .output()? - .stdout, - )?; - - let bgp_ipv6_routes_string = String::from_utf8( - Command::new("sh") - .env("VTYSH_HISTFILE", "/dev/null") - .args(["-c", "vtysh -c 'show ipv6 route bgp json'"]) - .output()? - .stdout, + let openfabric_routes = query_routes( + [ + "show ip route openfabric json".to_string(), + "show ipv6 route openfabric json".to_string(), + ], + "OpenFabric", )?; - let mut bgp_routes: proxmox_frr::de::Routes = if bgp_ipv4_routes_string.is_empty() { - proxmox_frr::de::Routes::default() - } else { - serde_json::from_str(&bgp_ipv4_routes_string) - .with_context(|| "error parsing bgp ipv4 routes")? - }; - if !bgp_ipv6_routes_string.is_empty() { - let bgp_ipv6_routes: proxmox_frr::de::Routes = - serde_json::from_str(&bgp_ipv6_routes_string) - .with_context(|| "error parsing bgp ipv6 routes")?; - bgp_routes.0.extend(bgp_ipv6_routes.0); + let node_id = NodeId::from_string(proxmox_sys::nodename().to_string())?; + let ospf_vrfs: BTreeSet<_> = config + .values() + .filter(|entry| { + matches!(entry, FabricEntry::Ospf(_)) && entry.get_node(&node_id).is_ok() + }) + .filter_map(fabric_vrf_name) + .collect(); + let mut ospf_commands = vec!["show ip route ospf json".to_string()]; + ospf_commands.extend( + ospf_vrfs + .iter() + .map(|vrf| format!("show ip route vrf {vrf} ospf json")), + ); + let ospf_routes = query_routes(ospf_commands, "OSPF")?; + + let bgp_vrfs: BTreeSet<_> = config + .values() + .filter(|entry| { + matches!(entry, FabricEntry::Bgp(_)) && entry.get_node(&node_id).is_ok() + }) + .filter_map(fabric_vrf_name) + .collect(); + let mut bgp_commands = vec![ + "show ip route bgp json".to_string(), + "show ipv6 route bgp json".to_string(), + ]; + for vrf in bgp_vrfs { + bgp_commands.extend( + ["ip", "ipv6"].map(|family| format!("show {family} route vrf {vrf} bgp json")), + ); } + let bgp_routes = query_routes(bgp_commands, "BGP")?; let route_status = status::RoutesParsed { openfabric: openfabric_routes, diff --git a/pve-rs/src/sdn/status.rs b/pve-rs/src/sdn/status.rs index 7a1334d20804..56a5646ded6d 100644 --- a/pve-rs/src/sdn/status.rs +++ b/pve-rs/src/sdn/status.rs @@ -18,6 +18,8 @@ use proxmox_ve_config::{ }, }; +use crate::bindings::pve_rs_sdn_fabrics::fabric_vrf_name; + // The status of a fabric interface // // Either up or down. @@ -535,6 +537,8 @@ pub fn get_status( continue; } let fabric_id = node.id().fabric_id(); + let vrf_name = + fabric_vrf_name(config.get_fabric(fabric_id)?).unwrap_or_else(|| "default".to_string()); let (current_protocol, all_routes) = match &node { ConfigNode::Openfabric(_) => (Protocol::Openfabric, &routes.openfabric.0), @@ -565,13 +569,14 @@ pub fn get_status( // determine status by checking if any routes exist for our interfaces let has_routes = all_routes.values().any(|v| { v.iter().any(|route| { - route.nexthops.iter().any(|nexthop| { - if let Some(iface_name) = &nexthop.interface_name { - interface_names.contains(iface_name.as_str()) - } else { - false - } - }) + route.vrf_name == vrf_name + && route.nexthops.iter().any(|nexthop| { + if let Some(iface_name) = &nexthop.interface_name { + interface_names.contains(iface_name.as_str()) + } else { + false + } + }) }) }); -- 2.47.3