From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id C01E81FF0E3 for ; Tue, 27 Oct 2026 09:59:24 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 17337218F9; Tue, 27 Oct 2026 09:56:35 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs v3 13/45] microseg: add a crate that applies the policy to a node's guest NICs Date: Tue, 27 Oct 2026 09:54:55 +0100 Message-ID: <20261027085527.702738-14-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261027085527.702738-1-h.laimer@proxmox.com> References: <20261027085527.702738-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793091347440 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.338 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) KAM_SHORT 0.001 Use of a URL Shortener for very short URL 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: A23TDILXR762BMS3UCRRRMX4NYGQ4BK4 X-Message-ID-Hash: A23TDILXR762BMS3UCRRRMX4NYGQ4BK4 X-MailFrom: h.laimer@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: The crate hands the microseg policy of the SDN running config to the policy subsystem of proxmox-ebpf. A full pass applies it to every guest NIC on the node, a plug to a single NIC that is about to come up. A validation needs only the config and tells whether the data plane can hold it. Only the microseg block of the running config is parsed. A plug runs for every guest NIC, so a zone or DHCP type that this node's proxmox-ve-config does not know yet must not keep guests from starting. The SDN apply on such a node fails either way. A guest's NICs come from the interfaces on the node and not from its config, since the config names a hot-plugged NIC only after the hotplug. Signed-off-by: Hannes Laimer --- Cargo.toml | 2 + proxmox-microseg/Cargo.toml | 20 +++ proxmox-microseg/debian/changelog | 5 + proxmox-microseg/debian/control | 46 +++++ proxmox-microseg/debian/copyright | 18 ++ proxmox-microseg/debian/debcargo.toml | 7 + proxmox-microseg/debian/source/format | 1 + proxmox-microseg/src/ebpf.rs | 235 +++++++++++++++++++++++++ proxmox-microseg/src/lib.rs | 66 +++++++ proxmox-microseg/src/nic.rs | 120 +++++++++++++ proxmox-microseg/src/running_config.rs | 26 +++ 11 files changed, 546 insertions(+) create mode 100644 proxmox-microseg/Cargo.toml create mode 100644 proxmox-microseg/debian/changelog create mode 100644 proxmox-microseg/debian/control create mode 100644 proxmox-microseg/debian/copyright create mode 100644 proxmox-microseg/debian/debcargo.toml create mode 100644 proxmox-microseg/debian/source/format create mode 100644 proxmox-microseg/src/ebpf.rs create mode 100644 proxmox-microseg/src/lib.rs create mode 100644 proxmox-microseg/src/nic.rs create mode 100644 proxmox-microseg/src/running_config.rs diff --git a/Cargo.toml b/Cargo.toml index 4655b178b47a..d38e48897216 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "proxmox-ve-config", "proxmox-frr", "proxmox-sdn-types", + "proxmox-microseg", ] exclude = [ "build", @@ -29,3 +30,4 @@ proxmox-network-types = { version = "1.1.2" } proxmox-schema = { version = "5" } proxmox-sdn-types = { version = "0.2", path = "proxmox-sdn-types" } proxmox-serde = { version = "1.0.0" } +proxmox-ve-config = { version = "0.10", path = "proxmox-ve-config" } diff --git a/proxmox-microseg/Cargo.toml b/proxmox-microseg/Cargo.toml new file mode 100644 index 000000000000..067b0f593990 --- /dev/null +++ b/proxmox-microseg/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "proxmox-microseg" +description = "Proxmox VE SDN microsegmentation data plane" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +exclude.workspace = true +# proxmox-ebpf needs it +rust-version = "1.88" + +[dependencies] +anyhow = { workspace = true } +log = "0.4" +serde = { workspace = true, features = [ "derive" ] } +serde_json = "1" + +proxmox-ebpf = { version = "0.1", features = [ "policy" ] } +proxmox-ve-config = { workspace = true } diff --git a/proxmox-microseg/debian/changelog b/proxmox-microseg/debian/changelog new file mode 100644 index 000000000000..e8f8ee14dcf9 --- /dev/null +++ b/proxmox-microseg/debian/changelog @@ -0,0 +1,5 @@ +rust-proxmox-microseg (0.1.0) trixie; urgency=medium + + * Initial release. + + -- Proxmox Support Team Tue, 22 Sep 2026 16:02:32 +0200 diff --git a/proxmox-microseg/debian/control b/proxmox-microseg/debian/control new file mode 100644 index 000000000000..35487025c2b7 --- /dev/null +++ b/proxmox-microseg/debian/control @@ -0,0 +1,46 @@ +Source: rust-proxmox-microseg +Section: rust +Priority: optional +Build-Depends: debhelper-compat (= 13), + dh-sequence-cargo +Build-Depends-Arch: cargo:native , + rustc:native (>= 1.88) , + libstd-rust-dev , + librust-anyhow-1+default-dev , + librust-log-0.4+default-dev , + librust-proxmox-ebpf-0.1+default-dev , + librust-proxmox-ebpf-0.1+policy-dev , + librust-proxmox-ve-config-0.10+default-dev , + librust-serde-1+default-dev , + librust-serde-1+derive-dev , + librust-serde-json-1+default-dev +Maintainer: Proxmox Support Team +Standards-Version: 4.7.2 +Vcs-Git: git://git.proxmox.com/git/proxmox-ve-rs.git +Vcs-Browser: https://git.proxmox.com/?p=proxmox-ve-rs.git +Homepage: https://proxmox.com +X-Cargo-Crate: proxmox-microseg + +Package: librust-proxmox-microseg-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-anyhow-1+default-dev, + librust-log-0.4+default-dev, + librust-proxmox-ebpf-0.1+default-dev, + librust-proxmox-ebpf-0.1+policy-dev, + librust-proxmox-ve-config-0.10+default-dev, + librust-serde-1+default-dev, + librust-serde-1+derive-dev, + librust-serde-json-1+default-dev +Provides: + librust-proxmox-microseg+default-dev (= ${binary:Version}), + librust-proxmox-microseg-0-dev (= ${binary:Version}), + librust-proxmox-microseg-0+default-dev (= ${binary:Version}), + librust-proxmox-microseg-0.1-dev (= ${binary:Version}), + librust-proxmox-microseg-0.1+default-dev (= ${binary:Version}), + librust-proxmox-microseg-0.1.0-dev (= ${binary:Version}), + librust-proxmox-microseg-0.1.0+default-dev (= ${binary:Version}) +Description: Proxmox VE SDN microsegmentation data plane - Rust source code + Source code for Debianized Rust crate "proxmox-microseg" diff --git a/proxmox-microseg/debian/copyright b/proxmox-microseg/debian/copyright new file mode 100644 index 000000000000..77952eba571e --- /dev/null +++ b/proxmox-microseg/debian/copyright @@ -0,0 +1,18 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: + * +Copyright: 2019 - 2026 Proxmox Server Solutions GmbH +License: AGPL-3.0-or-later + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any + later version. + . + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + details. + . + You should have received a copy of the GNU Affero General Public License along + with this program. If not, see . diff --git a/proxmox-microseg/debian/debcargo.toml b/proxmox-microseg/debian/debcargo.toml new file mode 100644 index 000000000000..87a787e6d03e --- /dev/null +++ b/proxmox-microseg/debian/debcargo.toml @@ -0,0 +1,7 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +vcs_git = "git://git.proxmox.com/git/proxmox-ve-rs.git" +vcs_browser = "https://git.proxmox.com/?p=proxmox-ve-rs.git" diff --git a/proxmox-microseg/debian/source/format b/proxmox-microseg/debian/source/format new file mode 100644 index 000000000000..89ae9db8f88b --- /dev/null +++ b/proxmox-microseg/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/proxmox-microseg/src/ebpf.rs b/proxmox-microseg/src/ebpf.rs new file mode 100644 index 000000000000..02ea16d5a2ef --- /dev/null +++ b/proxmox-microseg/src/ebpf.rs @@ -0,0 +1,235 @@ +use anyhow::{Context, bail}; +use proxmox_ebpf::policy::{self, Assignment, CHECK_MAX, CheckEntry, Family, Pair, Pass}; +use proxmox_ve_config::firewall::types::port::{PortEntry, PortList}; +use proxmox_ve_config::firewall::types::rule_match::{ + IcmpCode, IcmpType, Icmpv6Code, Icmpv6Type, Ports, Protocol, +}; +use proxmox_ve_config::sdn::microseg::MicrosegRunningConfig; +use proxmox_ve_config::sdn::microseg::state::{self, DesiredState}; + +use crate::nic::{self, LocalNics}; + +pub use proxmox_ebpf::policy::{ExclusiveLock, PAIRS_MAX, SharedLock, lock_exclusive, lock_shared}; + +fn port_bounds(list: Option<&PortList>) -> Vec> { + let Some(list) = list else { + // without a port list every port matches + return vec![None]; + }; + list.iter() + .map(|entry| { + Some(match entry { + PortEntry::Port(port) => (*port, *port), + PortEntry::Range(from, to) => (*from, *to), + }) + }) + .collect() +} + +/// One entry per combination of source and destination range, since an entry carries a single range +/// of each and both have to match. +fn port_entries(ip_proto: u8, ports: &Ports, allow: bool) -> Vec { + let dports = port_bounds(ports.dport()); + let mut entries = Vec::new(); + for sport in port_bounds(ports.sport()) { + for &dport in &dports { + entries.push(CheckEntry { + ip_proto: Some(ip_proto), + sport, + dport, + allow, + ..Default::default() + }); + } + } + entries +} + +fn protocol_entries(protocol: &Protocol, allow: bool) -> anyhow::Result> { + let Some(ip_proto) = protocol.number() else { + bail!("protocol {protocol:?} was not resolved to a number"); + }; + Ok(match protocol { + Protocol::Tcp(tcp) => port_entries(ip_proto, tcp.ports(), allow), + Protocol::Udp(udp) => port_entries(ip_proto, udp.ports(), allow), + Protocol::Sctp(sctp) => port_entries(ip_proto, sctp.ports(), allow), + Protocol::Dccp(ports) | Protocol::UdpLite(ports) => port_entries(ip_proto, ports, allow), + // an icmp match covers IPv4 frames and an icmpv6 match IPv6 frames, which the protocol + // number alone does not say + Protocol::Icmp(icmp) => vec![CheckEntry { + ip_proto: Some(ip_proto), + family: Family::V4, + icmp_type: icmp.ty().and_then(IcmpType::number), + icmp_code: icmp.code().map(IcmpCode::number), + allow, + ..Default::default() + }], + Protocol::Icmpv6(icmpv6) => vec![CheckEntry { + ip_proto: Some(ip_proto), + family: Family::V6, + icmp_type: icmpv6.ty().and_then(Icmpv6Type::number), + icmp_code: icmpv6.code().map(Icmpv6Code::number), + allow, + ..Default::default() + }], + Protocol::Named(_) | Protocol::Numeric(_) => vec![CheckEntry { + ip_proto: Some(ip_proto), + allow, + ..Default::default() + }], + }) +} + +/// Pairs share checks, so translate each check once up front instead of once per pair. +fn check_entries(state: &DesiredState) -> anyhow::Result>> { + let mut checks = Vec::with_capacity(state.checks.len()); + for check in &state.checks { + let mut entries = Vec::new(); + for protocol in &check.protocols { + entries.extend(protocol_entries(protocol, check.allow)?); + } + checks.push(entries); + } + Ok(checks) +} + +fn pair_entries( + checks: &[Vec], + pair: &state::Pair, + cfg: &MicrosegRunningConfig, +) -> anyhow::Result> { + let mut entries = Vec::new(); + for &check in &pair.checks { + entries.extend_from_slice(&checks[check]); + } + // proxmox-ebpf refuses such a pair as well, but cannot name the classes by their groups + if entries.len() > CHECK_MAX { + bail!( + "pair {} -> {} needs {} checks, a pair holds at most {CHECK_MAX}", + cfg.describe_class(pair.src_id), + cfg.describe_class(pair.dst_id), + entries.len(), + ); + } + Ok(entries) +} + +fn pairs(state: &DesiredState, cfg: &MicrosegRunningConfig) -> anyhow::Result> { + let checks = check_entries(state)?; + let mut pairs = Vec::with_capacity(state.pairs.len()); + for pair in &state.pairs { + pairs.push(Pair { + src_id: pair.src_id, + dst_id: pair.dst_id, + checks: pair_entries(&checks, pair, cfg)?, + allow: pair.allow, + }); + } + Ok(pairs) +} + +pub struct FullPass { + configured: bool, + assignments: Vec, + pairs: Vec, +} + +pub fn full_pass( + nics: &LocalNics, + state: &DesiredState, + cfg: &MicrosegRunningConfig, +) -> anyhow::Result { + let assignments = state + .nics + .iter() + .map(|resolved| { + let Some(name) = nics.name(resolved.vmid, resolved.iface) else { + bail!( + "NIC vm{} net{} has no interface on this node", + resolved.vmid, + resolved.iface + ); + }; + Ok(Assignment { + iface: name.to_owned(), + id: resolved.id, + firewall_bridge: nic::behind_firewall_bridge(name), + }) + }) + .collect::>>()?; + Ok(FullPass { + configured: state.configured, + assignments, + pairs: pairs(state, cfg)?, + }) +} + +pub fn check(state: &DesiredState, cfg: &MicrosegRunningConfig) -> anyhow::Result<()> { + policy::check(&pairs(state, cfg)?).context("the data plane cannot take the microseg policy") +} + +pub fn apply(lock: &ExclusiveLock, pass: &FullPass) -> anyhow::Result<()> { + let applied = lock.apply(pass.configured, &pass.assignments, &pass.pairs)?; + if applied != Pass::Applied { + return Ok(()); + } + let level = if pass.assignments.is_empty() { + log::Level::Debug + } else { + log::Level::Info + }; + log::log!( + level, + "microseg: applied {} assigned NICs on this node, {} pairs", + pass.assignments.len(), + pass.pairs.len() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use proxmox_ve_config::firewall::types::rule_match::Tcp; + use proxmox_ve_config::sdn::microseg::state::Check; + + use super::*; + + fn ports(list: &str) -> PortList { + list.parse().expect("valid port list") + } + + /// The entries depend on nothing but the one check. + fn state(protocols: Vec) -> DesiredState { + DesiredState { + configured: true, + pairs: vec![state::Pair { + src_id: 1, + dst_id: 2, + checks: vec![0], + allow: false, + }], + checks: vec![Check { + protocols, + allow: true, + }], + nics: Vec::new(), + } + } + + #[test] + fn check_rejects_what_the_apply_would() { + let elements: Vec = (1..=CHECK_MAX + 1).map(|port| port.to_string()).collect(); + let tcp = Tcp::new(Ports::new(None, ports(&elements.join(",")))); + let over = state(vec![Protocol::Tcp(tcp)]); + let cfg: MicrosegRunningConfig = serde_json::from_str( + r#"{"ids":{},"identities":{"next":3,"classes":{"1":["web"],"2":["db"]}}}"#, + ) + .expect("parse running config"); + let error = check(&over, &cfg).expect_err("past the bound"); + let message = format!("{error:#}"); + assert!(message.contains("pair 1 (web) -> 2 (db)"), "{message}"); + + let tcp = Tcp::new(Ports::new(None, ports("22"))); + check(&state(vec![Protocol::Tcp(tcp)]), &cfg).expect("an ordinary pair"); + } +} diff --git a/proxmox-microseg/src/lib.rs b/proxmox-microseg/src/lib.rs new file mode 100644 index 000000000000..4646d70982d1 --- /dev/null +++ b/proxmox-microseg/src/lib.rs @@ -0,0 +1,66 @@ +mod ebpf; +mod nic; +mod running_config; + +use anyhow::{Context, bail}; +use proxmox_ve_config::sdn::microseg::state::{DesiredState, resolve_iface}; +use proxmox_ve_config::sdn::microseg::{GuestInfo, MicrosegRunningConfig}; + +pub use crate::ebpf::{ExclusiveLock, SharedLock, lock_exclusive, lock_shared}; +pub use crate::running_config::parse as parse_running_config; + +fn full_pass( + cfg: &MicrosegRunningConfig, + inventory: &[GuestInfo], +) -> anyhow::Result { + let nics = nic::LocalNics::list()?; + let state = DesiredState::build(cfg, &nics.guests(inventory), ebpf::PAIRS_MAX) + .context("desired state")?; + ebpf::full_pass(&nics, &state, cfg) +} + +pub fn apply( + lock: &ExclusiveLock, + cfg: &MicrosegRunningConfig, + inventory: &[GuestInfo], +) -> anyhow::Result<()> { + ebpf::apply(lock, &full_pass(cfg, inventory)?) +} + +/// Fails for a config the data plane could not install. The pairs alone decide that, and they +/// cover every allocated class whatever the guests match, so no inventory is needed. +pub fn check(cfg: &MicrosegRunningConfig) -> anyhow::Result<()> { + let state = DesiredState::build(cfg, &[], ebpf::PAIRS_MAX).context("desired state")?; + ebpf::check(&state, cfg) +} + +/// Enforce the policy on a guest NIC that is about to come up. An assigned NIC gets its class. A +/// NIC no assignment covers loses what an earlier plug or pass placed for its index, so one that +/// comes up again in place drops its old identity. A failure in either case is an error rather +/// than a skip, since the NIC would come up unenforced or as a class it left. `guest` is the +/// plugged guest as the inventory lists it. `firewall_bridge` says the NIC is about to be put +/// behind a firewall bridge. +pub fn apply_iface( + lock: &SharedLock, + cfg: &MicrosegRunningConfig, + guest: &GuestInfo, + ifname: &str, + firewall_bridge: bool, +) -> anyhow::Result<()> { + let Some((vmid, iface)) = nic::parse(ifname) else { + log::warn!("microseg: {ifname} is not a guest NIC name, skipping"); + return Ok(()); + }; + if vmid != guest.vmid { + bail!( + "{ifname} is a NIC of guest {vmid}, not of guest {}", + guest.vmid + ); + } + let Some(id) = resolve_iface(cfg, guest, iface)? else { + log::debug!("microseg: {ifname} is unassigned"); + lock.release_iface(ifname)?; + return Ok(()); + }; + lock.apply_iface(ifname, id, firewall_bridge) +} diff --git a/proxmox-microseg/src/nic.rs b/proxmox-microseg/src/nic.rs new file mode 100644 index 000000000000..6a0b974b0f49 --- /dev/null +++ b/proxmox-microseg/src/nic.rs @@ -0,0 +1,120 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use anyhow::Context; +use proxmox_ve_config::sdn::microseg::GuestInfo; + +/// The guest and the NIC index a host interface name stands for. +pub fn parse(name: &str) -> Option<(u32, u32)> { + let rest = name + .strip_prefix("tap") + .or_else(|| name.strip_prefix("veth"))?; + let (vmid, iface) = rest.split_once('i')?; + // the integer parser alone would also take a sign + let number = |digits: &str| -> Option { + digits + .bytes() + .all(|byte| byte.is_ascii_digit()) + .then(|| digits.parse().ok()) + .flatten() + }; + Some((number(vmid)?, number(iface)?)) +} + +/// A guest without a name or tags may be missing from the inventory. An assignment by VMID covers +/// it all the same, so it is known by its VMID alone. +fn guest(known: Option<&GuestInfo>, vmid: u32) -> GuestInfo { + known.cloned().unwrap_or(GuestInfo { + vmid, + ..Default::default() + }) +} + +pub fn behind_firewall_bridge(name: &str) -> bool { + let Some((vmid, iface)) = parse(name) else { + return false; + }; + std::fs::read_link(format!("/sys/class/net/{name}/master")).is_ok_and(|bridge| { + bridge + .file_name() + .is_some_and(|master| master.to_string_lossy() == format!("fwbr{vmid}i{iface}")) + }) +} + +/// The node says which NICs a guest has, since its config does not name a hot-plugged NIC until the +/// plug is through. +pub struct LocalNics(BTreeMap<(u32, u32), String>); + +impl LocalNics { + pub fn list() -> anyhow::Result { + // a failure must not read as a node without guest NICs, a full pass would then take + // enforcement off every one of them + let names = std::fs::read_dir("/sys/class/net") + .and_then(|entries| { + entries + .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned())) + .collect::>>() + }) + .context("list the interfaces of this node")?; + Ok(Self::from_names(names)) + } + + fn from_names(names: impl IntoIterator) -> Self { + let nics = names + .into_iter() + .filter_map(|name| Some((parse(&name)?, name))); + Self(nics.collect()) + } + + pub fn name(&self, vmid: u32, iface: u32) -> Option<&str> { + self.0.get(&(vmid, iface)).map(String::as_str) + } + + pub fn guests(&self, inventory: &[GuestInfo]) -> Vec { + let mut nics: BTreeMap> = BTreeMap::new(); + for &(vmid, iface) in self.0.keys() { + nics.entry(vmid).or_default().insert(iface); + } + let known: HashMap = inventory.iter().map(|g| (g.vmid, g)).collect(); + nics.into_iter() + .map(|(vmid, nics)| GuestInfo { + nics: nics.into_iter().collect(), + ..guest(known.get(&vmid).copied(), vmid) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_guest_has_the_nics_the_node_has() { + let inventory = [ + GuestInfo { + vmid: 100, + name: Some("web".to_string()), + nics: vec![0, 5], + ..Default::default() + }, + GuestInfo { + vmid: 300, + nics: vec![0], + ..Default::default() + }, + ]; + let names = ["lo", "tap100i1", "fwln100i0", "veth200i0", "tap100i0"]; + let nics = LocalNics::from_names(names.map(str::to_string)); + let guests = nics.guests(&inventory); + assert_eq!(nics.name(200, 0), Some("veth200i0")); + + // net1 is not in the config of vm100 yet and net5 is not plugged, vm300 runs elsewhere + assert_eq!(guests.len(), 2); + assert_eq!(guests[0].vmid, 100); + assert_eq!(guests[0].name.as_deref(), Some("web")); + assert_eq!(guests[0].nics, [0, 1]); + assert_eq!(guests[1].vmid, 200); + assert_eq!(guests[1].name, None); + assert_eq!(guests[1].nics, [0]); + } +} diff --git a/proxmox-microseg/src/running_config.rs b/proxmox-microseg/src/running_config.rs new file mode 100644 index 000000000000..dbf8160de196 --- /dev/null +++ b/proxmox-microseg/src/running_config.rs @@ -0,0 +1,26 @@ +use proxmox_ve_config::sdn::microseg::MicrosegRunningConfig; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Sections { + microseg: Option, +} + +/// `None` for a running config without a microseg block. Every apply writes the block, even an +/// empty one, so only a node without microseg support leaves it out. Such a document says nothing +/// about the policy, it does not declare an empty one. +pub fn parse(raw: &str) -> anyhow::Result> { + let sections: Sections = serde_json::from_str(raw)?; + Ok(sections.microseg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_block_is_absent() { + let cfg = parse(r#"{"zones": {"ids": {}}}"#).expect("parse running config"); + assert_eq!(cfg, None); + } +} -- 2.47.3