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 1651D1FF0E0 for ; Thu, 09 Jul 2026 11:20:06 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 8CA8C2151B; Thu, 09 Jul 2026 11:19:37 +0200 (CEST) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs v2 05/27] ve-config: sdn: microseg: add carrier bridge section Date: Thu, 9 Jul 2026 11:18:30 +0200 Message-ID: <20260709091852.538885-6-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260709091852.538885-1-h.laimer@proxmox.com> References: <20260709091852.538885-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: 1783588732444 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.500 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) 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: YFSRBRAMMJUJU7RBI72VQTCNCOTLR5FN X-Message-ID-Hash: YFSRBRAMMJUJU7RBI72VQTCNCOTLR5FN 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: A bridge section marks a bridge-facing interface as an identity carrier, optionally restricted to a set of nodes. A carrier is only needed where the underlay cannot transport the identity itself. Signed-off-by: Hannes Laimer --- proxmox-ve-config/src/sdn/microseg/mod.rs | 98 +++++++++++++++++++++-- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs index 8522a2c..8c38980 100644 --- a/proxmox-ve-config/src/sdn/microseg/mod.rs +++ b/proxmox-ve-config/src/sdn/microseg/mod.rs @@ -308,6 +308,36 @@ pub struct RegexAssignmentSection { pub(crate) comment: Option, } +#[api] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// A bridge-facing interface that carries the policy tag across hosts. +pub struct BridgeSection { + pub(crate) id: MicrosegId, + /// Comma-separated list of nodes this bridge applies on (empty or absent means all nodes). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) nodes: Option, +} + +impl BridgeSection { + /// Iterate the node-name list, trimming whitespace and skipping empty entries. + pub fn nodes(&self) -> impl Iterator { + self.nodes + .as_deref() + .into_iter() + .flat_map(|s| s.split(',')) + .map(str::trim) + .filter(|n| !n.is_empty()) + } + /// Whether this bridge applies on `node`. Empty / absent `nodes` means all nodes. + pub fn applies_to(&self, node: &str) -> bool { + let mut iter = self.nodes(); + match iter.next() { + None => true, + Some(first) => first == node || iter.any(|n| n == node), + } + } +} + #[api( "id-property": "id", "id-schema": { @@ -331,6 +361,8 @@ pub enum MicrosegEntry { TagAssignment(TagAssignmentSection), /// A name-regex-matcher NIC-to-groups assignment. RegexAssignment(RegexAssignmentSection), + /// A bridge-facing carrier interface. + Bridge(BridgeSection), } impl MicrosegEntry { @@ -342,6 +374,7 @@ impl MicrosegEntry { Self::GuestAssignment(a) => &a.id, Self::TagAssignment(a) => &a.id, Self::RegexAssignment(a) => &a.id, + Self::Bridge(b) => &b.id, } } } @@ -433,6 +466,13 @@ impl MicrosegRunningConfig { }) } + pub fn bridges(&self) -> impl Iterator + '_ { + self.ids.iter().filter_map(|(k, v)| match v { + MicrosegEntry::Bridge(b) => Some((k.as_str(), b)), + _ => None, + }) + } + /// The rendered concrete per-NIC assignments (static plus selector-expanded) the agent enforces. pub fn realized(&self) -> &[RealizedAssignment] { &self.realized @@ -526,7 +566,7 @@ pub fn validate(entries: &HashMap) -> Result<(), anyhow:: } check_assignment_groups(name, &assignment.groups)?; } - MicrosegEntry::Group(_) => {} + MicrosegEntry::Group(_) | MicrosegEntry::Bridge(_) => {} } } @@ -645,7 +685,7 @@ pub fn realized_assignments( regex_matchers.push((re, assignment)); } } - MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {} + MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) | MicrosegEntry::Bridge(_) => {} } } @@ -741,8 +781,7 @@ pub fn realized_per_assignment( Err(_) => Vec::new(), } } - - MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => { + MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) | MicrosegEntry::Bridge(_) => { return None; } }; @@ -939,7 +978,7 @@ pub mod api { use serde::Deserialize; use super::{ - GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId, + BridgeSection, GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId, PredicateMatch, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection, }; @@ -951,6 +990,7 @@ pub mod api { GuestAssignment(GuestAssignmentCreate), TagAssignment(TagAssignmentCreate), RegexAssignment(RegexAssignmentCreate), + Bridge(BridgeCreate), } #[derive(Debug, Clone, Deserialize)] @@ -1018,6 +1058,13 @@ pub mod api { pub comment: Option, } + #[derive(Debug, Clone, Deserialize)] + pub struct BridgeCreate { + pub id: MicrosegId, + #[serde(default)] + pub nodes: Option, + } + #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum MicrosegUpdate { @@ -1026,6 +1073,7 @@ pub mod api { GuestAssignment(AssignmentUpdate), TagAssignment(AssignmentUpdate), RegexAssignment(AssignmentUpdate), + Bridge(BridgeUpdate), } #[derive(Debug, Clone, Default, Deserialize)] @@ -1076,6 +1124,20 @@ pub mod api { Comment, } + #[derive(Debug, Clone, Default, Deserialize)] + pub struct BridgeUpdate { + #[serde(default)] + pub nodes: Option, + #[serde(default)] + pub delete: Vec, + } + + #[derive(Debug, Clone, Copy, Deserialize)] + #[serde(rename_all = "lowercase")] + pub enum BridgeDeletableProperty { + Nodes, + } + /// Lowest unused group mark in `1..=65535`. pub fn next_free_mark(entries: &HashMap) -> Option { let used: HashSet = entries @@ -1263,6 +1325,10 @@ pub mod api { comment: assignment.comment, }) } + MicrosegCreate::Bridge(bridge) => MicrosegEntry::Bridge(BridgeSection { + id: bridge.id, + nodes: bridge.nodes, + }), }) } @@ -1341,6 +1407,16 @@ pub mod api { } } } + (MicrosegEntry::Bridge(bridge), MicrosegUpdate::Bridge(update)) => { + if update.nodes.is_some() { + bridge.nodes = update.nodes; + } + for property in update.delete { + match property { + BridgeDeletableProperty::Nodes => bridge.nodes = None, + } + } + } _ => bail!("update type does not match the existing object's type"), } Ok(()) @@ -1407,7 +1483,7 @@ pub mod api { }; (base, assignment.comment.as_deref()) } - MicrosegEntry::Group(_) => ("object".to_string(), None), + MicrosegEntry::Group(_) | MicrosegEntry::Bridge(_) => ("object".to_string(), None), }; match comment { Some(comment) => format!("{desc} (comment: \"{comment}\")"), @@ -1437,7 +1513,7 @@ pub mod api { MicrosegEntry::RegexAssignment(assignment) => { assignment.groups.iter().any(|g| g.as_ref() == name) } - MicrosegEntry::Group(_) => false, + MicrosegEntry::Group(_) | MicrosegEntry::Bridge(_) => false, }) .map(describe_referrer) .collect(); @@ -1573,6 +1649,13 @@ mod tests { comment: None, }), ), + ( + "vmbr0".to_string(), + MicrosegEntry::Bridge(BridgeSection { + id: id("vmbr0"), + nodes: Some("pve1,pve2".to_string()), + }), + ), ]); validate(&entries).expect("config is valid"); @@ -2148,7 +2231,6 @@ mod tests { assert!(realized_per_assignment(&entries, &inv)["sel"].is_empty()); } - #[test] fn render_with_inventory_allocates_tag_identities_and_round_trips() { use PredicateMatch::Any; -- 2.47.3