all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ve-rs v2 03/27] ve-config: sdn: microseg: add tag matcher
Date: Thu,  9 Jul 2026 11:18:28 +0200	[thread overview]
Message-ID: <20260709091852.538885-4-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260709091852.538885-1-h.laimer@proxmox.com>

A second NIC-to-group assignment matcher next to the guest matcher:
bind every guest matching a set of tags (by any/all/exact) to a set of
groups. Expands against the live guest inventory, so a guest tag change
surfaces as a pending change. Its id is derived from the tags, match
kind and optional iface so identical matchers collapse. An empty tag
matcher is rejected on validation.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 proxmox-ve-config/src/sdn/microseg/mod.rs | 467 +++++++++++++++++++++-
 1 file changed, 458 insertions(+), 9 deletions(-)

diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs
index 555effa..fc7d0e2 100644
--- a/proxmox-ve-config/src/sdn/microseg/mod.rs
+++ b/proxmox-ve-config/src/sdn/microseg/mod.rs
@@ -31,6 +31,22 @@ api_string_type! {
     pub struct MicrosegId(String);
 }
 
+pub const MICROSEG_TAG_REGEX_STR: &str = r"[a-zA-Z0-9_][a-zA-Z0-9_+.\-]*";
+
+const_regex! {
+    pub MICROSEG_TAG_REGEX = concatcp!(r"^", MICROSEG_TAG_REGEX_STR, r"$");
+}
+
+pub const MICROSEG_TAG_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&MICROSEG_TAG_REGEX);
+
+api_string_type! {
+    /// A guest tag a [`TagAssignmentSection`] matches on. Matched case-sensitively against the
+    /// guest's configured tags.
+    #[api(format: &MICROSEG_TAG_FORMAT)]
+    #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, UpdaterType)]
+    pub struct Tag(String);
+}
+
 #[api()]
 #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
 #[serde(rename_all = "lowercase")]
@@ -191,7 +207,7 @@ impl RuleSection {
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
 /// Binds a specific guest's NIC(s) to a set of groups: all of guest `vmid`'s NICs, or just the one
 /// named by `iface` when set. Stored as the `guestassignment` section type. A new matcher kind is a
-/// new sibling section type discriminated by `type`, not a new field here.
+/// new sibling section type (see [`TagAssignmentSection`]), not a new field here.
 pub struct GuestAssignmentSection {
     pub(crate) id: MicrosegId,
     pub(crate) vmid: u32,
@@ -203,6 +219,51 @@ pub struct GuestAssignmentSection {
     pub(crate) comment: Option<String>,
 }
 
+#[api(
+    properties: {
+        tags: {
+            type: Array,
+            description: "Guest tags to match on.",
+            items: { type: String, description: "A guest tag.", format: &MICROSEG_TAG_FORMAT },
+        },
+        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 matching `tags` (by `tag_match`) to a set of groups. Expanded
+/// against the live guest inventory at render time, so a guest tag change surfaces as a pending SDN
+/// change and takes effect on the next apply. Stored as the `tagassignment` section type.
+pub struct TagAssignmentSection {
+    pub(crate) id: MicrosegId,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub(crate) tags: Vec<Tag>,
+    #[serde(default)]
+    pub(crate) tag_match: PredicateMatch,
+    #[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": {
@@ -222,6 +283,8 @@ pub enum MicrosegEntry {
     Rule(RuleSection),
     /// A guest-matcher NIC-to-groups assignment.
     GuestAssignment(GuestAssignmentSection),
+    /// A tag-matcher NIC-to-groups assignment.
+    TagAssignment(TagAssignmentSection),
 }
 
 impl MicrosegEntry {
@@ -231,6 +294,7 @@ impl MicrosegEntry {
             Self::Group(g) => &g.id,
             Self::Rule(r) => &r.id,
             Self::GuestAssignment(a) => &a.id,
+            Self::TagAssignment(a) => &a.id,
         }
     }
 }
@@ -274,6 +338,8 @@ where
 pub struct GuestInfo {
     #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")]
     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, deserialize_with = "deserialize_perl_nics")]
     pub nics: Vec<u32>,
@@ -395,6 +461,12 @@ pub fn validate(entries: &HashMap<String, MicrosegEntry>) -> Result<(), anyhow::
             MicrosegEntry::GuestAssignment(assignment) => {
                 check_assignment_groups(name, &assignment.groups)?;
             }
+            MicrosegEntry::TagAssignment(assignment) => {
+                if assignment.tags.is_empty() {
+                    bail!("assignment '{name}' has an empty tag matcher");
+                }
+                check_assignment_groups(name, &assignment.groups)?;
+            }
             MicrosegEntry::Group(_) => {}
         }
     }
@@ -459,6 +531,20 @@ pub fn build_policies(
         .collect()
 }
 
+/// Whether the tag set fires on `guest`, per the match kind.
+fn tags_fire(tags: &[Tag], tag_match: PredicateMatch, guest: &GuestInfo) -> bool {
+    let has = |tag: &Tag| guest.tags.iter().any(|t| t == tag);
+    match tag_match {
+        PredicateMatch::Any => tags.iter().any(|t| has(t)),
+        PredicateMatch::All => tags.iter().all(|t| has(t)),
+        PredicateMatch::Exact => {
+            let want: BTreeSet<&Tag> = tags.iter().collect();
+            let have: BTreeSet<&Tag> = guest.tags.iter().collect();
+            want == have
+        }
+    }
+}
+
 fn covered_nics(iface: Option<u32>, nics: &[u32]) -> Vec<u32> {
     match iface {
         Some(iface) => nics.iter().copied().filter(|&nic| nic == iface).collect(),
@@ -475,6 +561,7 @@ pub fn realized_assignments(
     inventory: &[GuestInfo],
 ) -> 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 nics_of: HashMap<u32, &[u32]> = inventory
         .iter()
@@ -492,10 +579,25 @@ pub fn realized_assignments(
                         .extend(assignment.groups.iter().cloned());
                 }
             }
+            MicrosegEntry::TagAssignment(assignment) => tag_matchers.push(assignment),
             MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {}
         }
     }
 
+    for guest in inventory {
+        for assignment in &tag_matchers {
+            if !tags_fire(&assignment.tags, assignment.tag_match, guest) {
+                continue;
+            }
+            for nic in covered_nics(assignment.iface, &guest.nics) {
+                merged
+                    .entry((guest.vmid, nic))
+                    .or_default()
+                    .extend(assignment.groups.iter().cloned());
+            }
+        }
+    }
+
     merged
         .into_iter()
         .map(|((vmid, iface), groups)| RealizedAssignment {
@@ -536,6 +638,17 @@ pub fn realized_per_assignment(
                         }]
                     }
                 }
+                MicrosegEntry::TagAssignment(assignment) => inventory
+                    .iter()
+                    .filter(|guest| tags_fire(&assignment.tags, assignment.tag_match, guest))
+                    .filter_map(|guest| {
+                        let nics = covered_nics(assignment.iface, &guest.nics);
+                        (!nics.is_empty()).then(|| AssignmentMember {
+                            vmid: guest.vmid,
+                            nics,
+                        })
+                    })
+                    .collect(),
                 MicrosegEntry::Group(_) | MicrosegEntry::Rule(_) => {
                     return None;
                 }
@@ -734,7 +847,7 @@ pub mod api {
 
     use super::{
         GroupSection, GuestAssignmentSection, MicrosegEntry, MicrosegId,
-        PredicateMatch, RuleSection,
+        PredicateMatch, RuleSection, Tag, TagAssignmentSection,
     };
 
     #[derive(Debug, Clone, Deserialize)]
@@ -743,6 +856,7 @@ pub mod api {
         Group(GroupCreate),
         Rule(RuleCreate),
         GuestAssignment(GuestAssignmentCreate),
+        TagAssignment(TagAssignmentCreate),
     }
 
     #[derive(Debug, Clone, Deserialize)]
@@ -784,12 +898,27 @@ pub mod api {
         pub comment: Option<String>,
     }
 
+    #[derive(Debug, Clone, Deserialize)]
+    pub struct TagAssignmentCreate {
+        #[serde(default)]
+        pub tags: Vec<Tag>,
+        #[serde(default)]
+        pub tag_match: Option<PredicateMatch>,
+        #[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 {
         Group(GroupUpdate),
         Rule(RuleUpdate),
         GuestAssignment(AssignmentUpdate),
+        TagAssignment(AssignmentUpdate),
     }
 
     #[derive(Debug, Clone, Default, Deserialize)]
@@ -901,6 +1030,40 @@ pub mod api {
         format!("r{hash:016x}").parse().map_err(Error::from)
     }
 
+    /// A stable, content-derived id for a tag matcher (FNV-1a over its sorted tags, match kind and
+    /// optional interface), so identical tag matchers collapse and re-creating one is idempotent.
+    fn tag_assignment_id(
+        tags: &[Tag],
+        tag_match: PredicateMatch,
+        iface: Option<u32>,
+    ) -> Result<MicrosegId, Error> {
+        let mut sorted: Vec<&str> = tags.iter().map(AsRef::as_ref).collect();
+        sorted.sort_unstable();
+        sorted.dedup();
+        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);
+        };
+        feed(tag_match as u8);
+        match iface {
+            Some(iface) => {
+                feed(1);
+                for byte in iface.to_be_bytes() {
+                    feed(byte);
+                }
+            }
+            None => feed(0),
+        }
+        for tag in &sorted {
+            feed(0xff);
+            for byte in tag.as_bytes() {
+                feed(*byte);
+            }
+        }
+        format!("tag{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(
@@ -948,6 +1111,18 @@ pub mod api {
                     comment: assignment.comment,
                 })
             }
+            MicrosegCreate::TagAssignment(assignment) => {
+                let tag_match = assignment.tag_match.unwrap_or_default();
+                let id = tag_assignment_id(&assignment.tags, tag_match, assignment.iface)?;
+                MicrosegEntry::TagAssignment(TagAssignmentSection {
+                    id,
+                    tags: assignment.tags,
+                    tag_match,
+                    iface: assignment.iface,
+                    groups: assignment.groups,
+                    comment: assignment.comment,
+                })
+            }
         })
     }
 
@@ -997,6 +1172,19 @@ pub mod api {
                     }
                 }
             }
+            (MicrosegEntry::TagAssignment(assignment), MicrosegUpdate::TagAssignment(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(())
@@ -1036,6 +1224,24 @@ pub mod api {
                 },
                 assignment.comment.as_deref(),
             ),
+            MicrosegEntry::TagAssignment(assignment) => {
+                let tags = assignment
+                    .tags
+                    .iter()
+                    .map(|t| t.as_ref())
+                    .collect::<Vec<_>>()
+                    .join(", ");
+                let kind = match assignment.tag_match {
+                    PredicateMatch::Any => "any",
+                    PredicateMatch::All => "all",
+                    PredicateMatch::Exact => "exact",
+                };
+                let base = match assignment.iface {
+                    Some(iface) => format!("tag assignment {kind}{{{tags}}} on net{iface}"),
+                    None => format!("tag assignment {kind}{{{tags}}}"),
+                };
+                (base, assignment.comment.as_deref())
+            }
             MicrosegEntry::Group(_) => ("object".to_string(), None),
         };
         match comment {
@@ -1060,6 +1266,9 @@ pub mod api {
                 MicrosegEntry::GuestAssignment(assignment) => {
                     assignment.groups.iter().any(|g| g.as_ref() == name)
                 }
+                MicrosegEntry::TagAssignment(assignment) => {
+                    assignment.groups.iter().any(|g| g.as_ref() == name)
+                }
                 MicrosegEntry::Group(_) => false,
             })
             .map(describe_referrer)
@@ -1352,13 +1561,6 @@ mod tests {
         )
     }
 
-    fn guest(vmid: u32, _tag_names: &[&str], nics: &[u32]) -> GuestInfo {
-        GuestInfo {
-            vmid,
-            nics: nics.to_vec(),
-        }
-    }
-
     #[test]
     fn render_allocates_identities_and_round_trips() {
         let entries: HashMap<String, MicrosegEntry> = HashMap::from([
@@ -1591,6 +1793,253 @@ mod tests {
         validate(&ok).expect("a rule may name 'untagged'");
     }
 
+    fn tag(s: &str) -> Tag {
+        s.parse().expect("valid tag")
+    }
+
+    fn tags(names: &[&str]) -> Vec<Tag> {
+        names.iter().map(|n| tag(n)).collect()
+    }
+
+    fn tag_assignment(
+        name: &str,
+        tag_names: &[&str],
+        tag_match: PredicateMatch,
+        iface: Option<u32>,
+        group_names: &[&str],
+    ) -> (String, MicrosegEntry) {
+        (
+            name.to_string(),
+            MicrosegEntry::TagAssignment(TagAssignmentSection {
+                id: id(name),
+                tags: tags(tag_names),
+                tag_match,
+                iface,
+                groups: ids(group_names),
+                comment: None,
+            }),
+        )
+    }
+
+    fn guest(vmid: u32, tag_names: &[&str], nics: &[u32]) -> GuestInfo {
+        GuestInfo {
+            vmid,
+            tags: tags(tag_names),
+            nics: nics.to_vec(),
+        }
+    }
+
+    fn realized_groups(realized: &[RealizedAssignment]) -> HashMap<(u32, u32), Vec<String>> {
+        realized
+            .iter()
+            .map(|r| {
+                (
+                    (r.vmid, r.iface),
+                    r.groups.iter().map(|g| g.to_string()).collect(),
+                )
+            })
+            .collect()
+    }
+
+    #[test]
+    fn selector_expands_to_all_nics_and_merges_with_static() {
+        use PredicateMatch::Any;
+        // tags are guest-level, so a selector with no iface covers every NIC of a matching guest.
+        let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+            group("web", 2),
+            group("db", 3),
+            group("mon", 4),
+            assignment("vm100i0", 100, 0, &["web"]),
+            tag_assignment("sel_db", &["db"], Any, None, &["db"]),
+            tag_assignment("sel_mon", &["mon"], Any, None, &["mon"]),
+        ]);
+        // vm100 carries db and mon, and has net0 + net1.
+        let inv = vec![guest(100, &["db", "mon"], &[0, 1])];
+
+        let by_nic = realized_groups(&realized_assignments(&entries, &inv));
+        // net0 unions the static web with both selectors, net1 has only the selectors.
+        assert_eq!(by_nic[&(100, 0)], vec!["db", "mon", "web"]);
+        assert_eq!(by_nic[&(100, 1)], vec!["db", "mon"]);
+    }
+
+    #[test]
+    fn selector_tag_match_any_all_exact() {
+        use PredicateMatch::{All, Any, Exact};
+        let any = HashMap::from([
+            group("gg", 2),
+            tag_assignment("sel", &["a", "b"], Any, None, &["gg"]),
+        ]);
+        let all = HashMap::from([
+            group("gg", 2),
+            tag_assignment("sel", &["a", "b"], All, None, &["gg"]),
+        ]);
+        let exact = HashMap::from([
+            group("gg", 2),
+            tag_assignment("sel", &["a", "b"], Exact, None, &["gg"]),
+        ]);
+
+        let only_a = vec![guest(7, &["a"], &[0])];
+        assert_eq!(
+            realized_assignments(&any, &only_a).len(),
+            1,
+            "any shares a tag"
+        );
+        assert_eq!(
+            realized_assignments(&all, &only_a).len(),
+            0,
+            "all needs every tag"
+        );
+
+        let both = vec![guest(7, &["a", "b"], &[0])];
+        assert_eq!(
+            realized_assignments(&all, &both).len(),
+            1,
+            "all matches with both tags"
+        );
+
+        // exact needs the guest's tags to equal the listed set, no more no less
+        assert_eq!(
+            realized_assignments(&exact, &both).len(),
+            1,
+            "exact matches the equal set"
+        );
+        let extra = vec![guest(7, &["a", "b", "c"], &[0])];
+        assert_eq!(
+            realized_assignments(&exact, &extra).len(),
+            0,
+            "exact rejects a superset"
+        );
+    }
+
+    #[test]
+    fn selector_explicit_iface_restricts_to_one_nic() {
+        use PredicateMatch::Any;
+        let entries = HashMap::from([
+            group("web", 2),
+            tag_assignment("sel", &["t"], Any, Some(1), &["web"]),
+        ]);
+        let inv = vec![guest(5, &["t"], &[0, 1, 2])];
+        let realized = realized_assignments(&entries, &inv);
+        assert_eq!(realized.len(), 1);
+        assert_eq!((realized[0].vmid, realized[0].iface), (5, 1));
+    }
+
+    #[test]
+    fn selector_scoped_to_absent_nic_covers_nothing() {
+        use PredicateMatch::Any;
+        let entries = HashMap::from([
+            group("web", 2),
+            tag_assignment("sel", &["t"], Any, Some(3), &["web"]),
+        ]);
+        // the guest matches the tag but has no net3, so the scoped selector realizes no NIC and the
+        // guest must not surface under the assignment
+        let inv = vec![guest(5, &["t"], &[0])];
+        assert!(realized_assignments(&entries, &inv).is_empty());
+        assert!(realized_per_assignment(&entries, &inv)["sel"].is_empty());
+    }
+
+
+    #[test]
+    fn render_with_inventory_allocates_tag_identities_and_round_trips() {
+        use PredicateMatch::Any;
+        let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+            group("web", 2),
+            group("db", 3),
+            rule("r1", &["web"], &["db"], true),
+            tag_assignment("sel_web", &["frontend"], Any, None, &["web"]),
+            tag_assignment("sel_db", &["backend"], Any, None, &["db"]),
+        ]);
+        let inv = vec![
+            guest(100, &["frontend"], &[0]),
+            guest(200, &["backend"], &[0]),
+        ];
+
+        let running = render(&identity::Registry::new(), entries, &inv, 0).expect("render");
+        // {web} fires the src predicate, {db} the dst predicate -> two classes.
+        assert_eq!(running.identities().classes().len(), 2);
+        // two tagged NICs were realized.
+        assert_eq!(running.realized().len(), 2);
+
+        let json = serde_json::to_string(&running).expect("to json");
+        let back: MicrosegRunningConfig = serde_json::from_str(&json).expect("from json");
+        assert_eq!(
+            back, running,
+            "running config with realized assignments round trips"
+        );
+    }
+
+    #[test]
+    fn validate_rejects_bad_selectors() {
+        use PredicateMatch::Any;
+        let base: HashMap<String, MicrosegEntry> = HashMap::from([group("web", 2)]);
+
+        let mut no_tags = base.clone();
+        no_tags.extend([tag_assignment("s1", &[], Any, None, &["web"])]);
+        assert!(validate(&no_tags).is_err(), "empty tags must fail");
+
+        let mut no_groups = base.clone();
+        no_groups.extend([tag_assignment("s2", &["t"], Any, None, &[])]);
+        assert!(validate(&no_groups).is_err(), "empty groups must fail");
+
+        let mut unknown = base.clone();
+        unknown.extend([tag_assignment("s3", &["t"], Any, None, &["ghost"])]);
+        assert!(validate(&unknown).is_err(), "unknown group must fail");
+    }
+
+    #[test]
+    fn selector_section_config_round_trip() {
+        use PredicateMatch::All;
+        let entries: HashMap<String, MicrosegEntry> = HashMap::from([
+            group("web", 5),
+            group("db", 7),
+            tag_assignment("sel1", &["frontend", "prod"], All, Some(0), &["web", "db"]),
+        ]);
+        validate(&entries).expect("valid");
+
+        let data: SectionConfigData<MicrosegEntry> = SectionConfigData::from_iter(entries.clone());
+        let raw = MicrosegEntry::write_section_config("microseg.cfg", &data).expect("write");
+        let parsed: HashMap<String, MicrosegEntry> =
+            MicrosegEntry::parse_section_config("microseg.cfg", &raw)
+                .expect("parse")
+                .into_iter()
+                .collect();
+
+        assert_eq!(
+            parsed, entries,
+            "selector round trips through section config"
+        );
+    }
+
+    #[test]
+    fn create_tag_assignment_from_perl_scalars() {
+        // perlmod hands scalars as strings: iface "1" must parse and tag_match reads the enum form.
+        let create: api::MicrosegCreate = serde_json::from_str(
+            r#"{"type":"tagassignment","tags":["db","prod"],"tag_match":"all","iface":"1","groups":["web"]}"#,
+        )
+        .expect("assignment create");
+        match &create {
+            api::MicrosegCreate::TagAssignment(assignment) => {
+                assert_eq!(assignment.iface, Some(1));
+                assert_eq!(assignment.tag_match, Some(PredicateMatch::All));
+                assert_eq!(assignment.tags.len(), 2);
+            }
+            other => panic!("expected tag assignment, got {other:?}"),
+        }
+
+        let entries: HashMap<String, MicrosegEntry> = HashMap::from([group("web", 2)]);
+        let entry = api::build_entry(create, &entries).expect("build tag assignment");
+        // a tag matcher gets a content-derived id
+        assert!(entry.id().to_string().starts_with("tag"));
+        match entry {
+            MicrosegEntry::TagAssignment(assignment) => {
+                assert_eq!(assignment.tags, tags(&["db", "prod"]));
+                assert_eq!(assignment.tag_match, PredicateMatch::All);
+                assert_eq!(assignment.iface, Some(1));
+            }
+            other => panic!("expected tag assignment, got {other:?}"),
+        }
+    }
+
     #[test]
     fn guest_info_reads_perl_string_scalar_nics() {
         // perlmod hands array elements as untyped (string) scalars, the nics deserializer must read
-- 
2.47.3





  parent reply	other threads:[~2026-07-09  9:20 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 ` Hannes Laimer [this message]
2026-07-09  9:18 ` [PATCH proxmox-ve-rs v2 04/27] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
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-4-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.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal