From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher
Date: Thu, 9 Jul 2026 11:18:29 +0200 [thread overview]
Message-ID: <20260709091852.538885-5-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260709091852.538885-1-h.laimer@proxmox.com>
A third NIC-to-group assignment matcher next to the guest and tag
matchers: bind every guest whose name (qemu name / lxc hostname) matches
a regular expression. Like the tag matcher it expands against the live
guest inventory, so a rename surfaces as a pending change. Its id is
derived from the regex and optional iface so identical matchers
collapse. An empty or invalid regex is rejected on validation.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
proxmox-ve-config/src/sdn/microseg/mod.rs | 222 +++++++++++++++++++++-
1 file changed, 216 insertions(+), 6 deletions(-)
diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs
index fc7d0e2..8522a2c 100644
--- a/proxmox-ve-config/src/sdn/microseg/mod.rs
+++ b/proxmox-ve-config/src/sdn/microseg/mod.rs
@@ -10,6 +10,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use anyhow::{anyhow, bail};
use const_format::concatcp;
+use regex::Regex;
use serde::{Deserialize, Serialize};
use proxmox_schema::{ApiStringFormat, UpdaterType, api, api_string_type, const_regex};
@@ -264,6 +265,49 @@ pub struct TagAssignmentSection {
pub(crate) comment: Option<String>,
}
+#[api(
+ properties: {
+ name_regex: {
+ type: String,
+ max_length: 1024,
+ description: "Regular expression matched against each guest's name.",
+ },
+ iface: {
+ type: Integer,
+ optional: true,
+ minimum: 0,
+ maximum: 31,
+ description: "Restrict to the guest's netN interface. If unset, all NICs of each matching guest.",
+ },
+ groups: {
+ type: Array,
+ description: "Groups the matching NICs belong to.",
+ items: { type: String, description: "A group id.", format: &MICROSEG_ID_FORMAT },
+ },
+ comment: {
+ type: String,
+ optional: true,
+ max_length: 256,
+ description: "Free-form comment.",
+ },
+ },
+)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+/// Binds the NICs of every guest whose name matches `name_regex` to a set of groups. Expanded
+/// against the live guest inventory at render time, so a guest rename surfaces as a pending SDN
+/// change and takes effect on the next apply. Stored as the `regexassignment` section type.
+pub struct RegexAssignmentSection {
+ pub(crate) id: MicrosegId,
+ #[serde(default)]
+ pub(crate) name_regex: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) iface: Option<u32>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub(crate) groups: Vec<MicrosegId>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) comment: Option<String>,
+}
+
#[api(
"id-property": "id",
"id-schema": {
@@ -285,6 +329,8 @@ pub enum MicrosegEntry {
GuestAssignment(GuestAssignmentSection),
/// A tag-matcher NIC-to-groups assignment.
TagAssignment(TagAssignmentSection),
+ /// A name-regex-matcher NIC-to-groups assignment.
+ RegexAssignment(RegexAssignmentSection),
}
impl MicrosegEntry {
@@ -295,6 +341,7 @@ impl MicrosegEntry {
Self::Rule(r) => &r.id,
Self::GuestAssignment(a) => &a.id,
Self::TagAssignment(a) => &a.id,
+ Self::RegexAssignment(a) => &a.id,
}
}
}
@@ -340,18 +387,21 @@ pub struct GuestInfo {
pub vmid: u32,
#[serde(default)]
pub tags: Vec<Tag>,
- /// The present netN interface indices. A matcher with no `iface` covers all of them.
+ #[serde(default)]
+ pub name: Option<String>,
+ /// The present netN interface indices.
#[serde(default, deserialize_with = "deserialize_perl_nics")]
pub nics: Vec<u32>,
}
-/// The microseg block of the SDN running config (`running.microseg`), an `ids` map of entries
-/// keyed by section id.
+/// The microseg block of the SDN running config (`running.microseg`), an `ids` map of entries keyed
+/// by section id.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MicrosegRunningConfig {
#[serde(default)]
ids: HashMap<String, MicrosegEntry>,
- /// The compiled identity registry (id to representative set), keeping ids stable across renders.
+ /// The compiled identity registry (id to representative set), keeping ids stable across
+ /// renders.
#[serde(default)]
identities: identity::Registry,
/// Concrete per-NIC assignments after expanding selectors against the guest inventory and
@@ -467,6 +517,15 @@ pub fn validate(entries: &HashMap<String, MicrosegEntry>) -> Result<(), anyhow::
}
check_assignment_groups(name, &assignment.groups)?;
}
+ MicrosegEntry::RegexAssignment(assignment) => {
+ if assignment.name_regex.is_empty() {
+ bail!("assignment '{name}' has an empty name regex");
+ }
+ if let Err(err) = Regex::new(&assignment.name_regex) {
+ bail!("assignment '{name}' has an invalid name regex: {err}");
+ }
+ check_assignment_groups(name, &assignment.groups)?;
+ }
MicrosegEntry::Group(_) => {}
}
}
@@ -562,7 +621,8 @@ pub fn realized_assignments(
) -> Vec<RealizedAssignment> {
let mut merged: BTreeMap<(u32, u32), BTreeSet<MicrosegId>> = BTreeMap::new();
let mut tag_matchers: Vec<&TagAssignmentSection> = Vec::new();
- // vmid -> present NICs, so a guest matcher with no iface can cover all of the guest's NICs.
+ let mut regex_matchers: Vec<(Regex, &RegexAssignmentSection)> = Vec::new();
+
let nics_of: HashMap<u32, &[u32]> = inventory
.iter()
.map(|guest| (guest.vmid, guest.nics.as_slice()))
@@ -580,6 +640,11 @@ pub fn realized_assignments(
}
}
MicrosegEntry::TagAssignment(assignment) => tag_matchers.push(assignment),
+ MicrosegEntry::RegexAssignment(assignment) => {
+ if let Ok(re) = Regex::new(&assignment.name_regex) {
+ regex_matchers.push((re, assignment));
+ }
+ }
MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {}
}
}
@@ -596,6 +661,17 @@ pub fn realized_assignments(
.extend(assignment.groups.iter().cloned());
}
}
+ for (re, assignment) in ®ex_matchers {
+ if !guest.name.as_deref().is_some_and(|name| re.is_match(name)) {
+ continue;
+ }
+ for nic in covered_nics(assignment.iface, &guest.nics) {
+ merged
+ .entry((guest.vmid, nic))
+ .or_default()
+ .extend(assignment.groups.iter().cloned());
+ }
+ }
}
merged
@@ -649,6 +725,23 @@ pub fn realized_per_assignment(
})
})
.collect(),
+ MicrosegEntry::RegexAssignment(assignment) => {
+ match Regex::new(&assignment.name_regex) {
+ Ok(re) => inventory
+ .iter()
+ .filter(|guest| guest.name.as_deref().is_some_and(|n| re.is_match(n)))
+ .filter_map(|guest| {
+ let nics = covered_nics(assignment.iface, &guest.nics);
+ (!nics.is_empty()).then(|| AssignmentMember {
+ vmid: guest.vmid,
+ nics,
+ })
+ })
+ .collect(),
+ Err(_) => Vec::new(),
+ }
+ }
+
MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {
return None;
}
@@ -847,7 +940,7 @@ pub mod api {
use super::{
GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId,
- PredicateMatch, RuleSection, Tag, TagAssignmentSection,
+ PredicateMatch, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection,
};
#[derive(Debug, Clone, Deserialize)]
@@ -857,6 +950,7 @@ pub mod api {
Rule(RuleCreate),
GuestAssignment(GuestAssignmentCreate),
TagAssignment(TagAssignmentCreate),
+ RegexAssignment(RegexAssignmentCreate),
}
#[derive(Debug, Clone, Deserialize)]
@@ -912,6 +1006,18 @@ pub mod api {
pub comment: Option<String>,
}
+ #[derive(Debug, Clone, Deserialize)]
+ pub struct RegexAssignmentCreate {
+ #[serde(default)]
+ pub name_regex: String,
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_u32")]
+ pub iface: Option<u32>,
+ #[serde(default)]
+ pub groups: Vec<MicrosegId>,
+ #[serde(default)]
+ pub comment: Option<String>,
+ }
+
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum MicrosegUpdate {
@@ -919,6 +1025,7 @@ pub mod api {
Rule(RuleUpdate),
GuestAssignment(AssignmentUpdate),
TagAssignment(AssignmentUpdate),
+ RegexAssignment(AssignmentUpdate),
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -1064,6 +1171,29 @@ pub mod api {
format!("tag{hash:016x}").parse().map_err(Error::from)
}
+ /// A stable, content-derived id for a name-regex matcher (FNV-1a over its regex and optional
+ /// interface), so identical regex matchers collapse and re-creating one is idempotent.
+ fn regex_assignment_id(name_regex: &str, iface: Option<u32>) -> Result<MicrosegId, Error> {
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ let mut feed = |byte: u8| {
+ hash ^= byte as u64;
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+ };
+ match iface {
+ Some(iface) => {
+ feed(1);
+ for byte in iface.to_be_bytes() {
+ feed(byte);
+ }
+ }
+ None => feed(0),
+ }
+ for byte in name_regex.as_bytes() {
+ feed(*byte);
+ }
+ format!("re{hash:016x}").parse().map_err(Error::from)
+ }
+
/// Build an entry from a create payload, auto-assigning a free group mark when none is given and
/// deriving rule and assignment ids from their contents.
pub fn build_entry(
@@ -1123,6 +1253,16 @@ pub mod api {
comment: assignment.comment,
})
}
+ MicrosegCreate::RegexAssignment(assignment) => {
+ let id = regex_assignment_id(&assignment.name_regex, assignment.iface)?;
+ MicrosegEntry::RegexAssignment(RegexAssignmentSection {
+ id,
+ name_regex: assignment.name_regex,
+ iface: assignment.iface,
+ groups: assignment.groups,
+ comment: assignment.comment,
+ })
+ }
})
}
@@ -1185,6 +1325,22 @@ pub mod api {
}
}
}
+ (
+ MicrosegEntry::RegexAssignment(assignment),
+ MicrosegUpdate::RegexAssignment(update),
+ ) => {
+ if let Some(groups) = update.groups {
+ assignment.groups = groups;
+ }
+ if update.comment.is_some() {
+ assignment.comment = update.comment;
+ }
+ for property in update.delete {
+ match property {
+ AssignmentDeletableProperty::Comment => assignment.comment = None,
+ }
+ }
+ }
_ => bail!("update type does not match the existing object's type"),
}
Ok(())
@@ -1242,6 +1398,15 @@ pub mod api {
};
(base, assignment.comment.as_deref())
}
+ MicrosegEntry::RegexAssignment(assignment) => {
+ let base = match assignment.iface {
+ Some(iface) => {
+ format!("regex assignment /{}/ on net{iface}", assignment.name_regex)
+ }
+ None => format!("regex assignment /{}/", assignment.name_regex),
+ };
+ (base, assignment.comment.as_deref())
+ }
MicrosegEntry::Group(_) => ("object".to_string(), None),
};
match comment {
@@ -1269,6 +1434,9 @@ pub mod api {
MicrosegEntry::TagAssignment(assignment) => {
assignment.groups.iter().any(|g| g.as_ref() == name)
}
+ MicrosegEntry::RegexAssignment(assignment) => {
+ assignment.groups.iter().any(|g| g.as_ref() == name)
+ }
MicrosegEntry::Group(_) => false,
})
.map(describe_referrer)
@@ -1825,10 +1993,52 @@ mod tests {
GuestInfo {
vmid,
tags: tags(tag_names),
+ name: None,
+ nics: nics.to_vec(),
+ }
+ }
+
+ fn named_guest(vmid: u32, name: &str, nics: &[u32]) -> GuestInfo {
+ GuestInfo {
+ vmid,
+ tags: vec![],
+ name: Some(name.to_string()),
nics: nics.to_vec(),
}
}
+ #[test]
+ fn regex_assignment_matches_guest_names() {
+ let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+ group("web", 2),
+ (
+ "rweb".to_string(),
+ MicrosegEntry::RegexAssignment(RegexAssignmentSection {
+ id: id("rweb"),
+ name_regex: "^web-".to_string(),
+ iface: None,
+ groups: ids(&["web"]),
+ comment: None,
+ }),
+ ),
+ ]);
+ let inv = vec![
+ named_guest(1, "web-1", &[0]),
+ named_guest(2, "db-1", &[0]),
+ guest(3, &[], &[0]),
+ ];
+ let by_nic = realized_groups(&realized_assignments(&entries, &inv));
+ assert_eq!(by_nic[&(1, 0)], vec!["web".to_string()]);
+ assert!(
+ !by_nic.contains_key(&(2, 0)),
+ "a non-matching name is excluded"
+ );
+ assert!(
+ !by_nic.contains_key(&(3, 0)),
+ "a guest with no name never matches"
+ );
+ }
+
fn realized_groups(realized: &[RealizedAssignment]) -> HashMap<(u32, u32), Vec<String>> {
realized
.iter()
--
2.47.3
next prev parent reply other threads:[~2026-07-09 9:19 UTC|newest]
Thread overview: 28+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-09 9:18 SPAM: [RFC cluster/docs/ifupdown2/manager/network/proxmox{-ve-rs,-ebpf,-perl-rs} v2 00/27] sdn: add microsegmentation support Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 01/27] ve-config: sdn: add microseg signature-identity engine Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 02/27] ve-config: sdn: add microseg config types Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` Hannes Laimer [this message]
2026-07-09 9:18 ` [PATCH proxmox-ve-rs v2 05/27] ve-config: sdn: microseg: add carrier bridge section Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 06/27] agent: add userspace coordinator and stateless policy subsystem Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 07/27] bpf: add bridge subsystem Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-ebpf v2 08/27] debian: add packaging and boot-time oneshot unit Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-cluster v2 09/27] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-07-09 9:18 ` [PATCH proxmox-perl-rs v2 10/27] pve-rs: sdn: add microseg config binding Hannes Laimer
2026-07-09 9:18 ` [PATCH ifupdown2 v2 11/27] d/patches: add support for VXLAN-GBP flag Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 12/27] sdn: microseg: add config, API and guest inventory Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 13/27] sdn: dry-run: surface pending microseg changes Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 14/27] sdn: zones: trigger microseg apply on tap_plug Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 15/27] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 16/27] evpn: disable vxlan-learning on create if GBP is enabled Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 17/27] sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 18/27] sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-network v2 19/27] sdn: microseg: add carrier bridge API Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 20/27] ui: sdn: add microsegmentation panel Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 21/27] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 22/27] network: apply microseg state on reload Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 23/27] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 24/27] ui: sdn: microseg: add tag matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-manager v2 25/27] ui: sdn: microseg: add name regex matcher Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 26/27] sdn: add microsegmentation section Hannes Laimer
2026-07-09 9:18 ` [PATCH pve-docs v2 27/27] sdn: add VXLAN-GBP flag to evpn/vxlan zone sections Hannes Laimer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260709091852.538885-5-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.