From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 5CFB51FF185 for ; Mon, 17 Nov 2025 15:11:37 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 5BC1A1AB4C; Mon, 17 Nov 2025 15:11:40 +0100 (CET) From: Lukas Wagner To: pdm-devel@lists.proxmox.com Date: Mon, 17 Nov 2025 15:11:20 +0100 Message-ID: <20251117141122.328559-2-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251117141122.328559-1-l.wagner@proxmox.com> References: <20251117141122.328559-1-l.wagner@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1763388665414 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.970 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_MAILER 2 Automated Mailer Tag Left in Email SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH datacenter-manager 1/3] pdm-api-types: views: preparations for future glob/regex support X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" Change the config format slightly in a way similar to how the 'match-field' statements work for notification matchers. The new format is, in pseudo-regex: (include|exclude) (exact:)?(resource-pool|...|tag)=.* The 'exact:' part is optional. Later we can add "regex" or "glob" types to add support for globbing or regex without any changes to the config format. Signed-off-by: Lukas Wagner --- lib/pdm-api-types/src/views.rs | 185 +++++++++++++++++++++++---------- server/src/views/mod.rs | 24 +++-- server/src/views/tests.rs | 110 ++++++++++++-------- 3 files changed, 215 insertions(+), 104 deletions(-) diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs index ef39cc62..950a3a04 100644 --- a/lib/pdm-api-types/src/views.rs +++ b/lib/pdm-api-types/src/views.rs @@ -1,6 +1,6 @@ -use std::{fmt::Display, str::FromStr, sync::OnceLock}; +use std::{fmt::Debug, fmt::Display, str::FromStr, sync::OnceLock}; -use anyhow::bail; +use anyhow::{bail, Error}; use const_format::concatcp; use serde::{Deserialize, Serialize}; @@ -23,11 +23,11 @@ const_regex! { pub const FILTER_RULE_SCHEMA: Schema = StringSchema::new("Filter rule for resources.") .format(&ApiStringFormat::VerifyFn(verify_filter_rule)) .type_text( - "resource-type:\ - |resource-pool:\ - |tag:\ - |remote:\ - |resource-id:", + "[exact:]resource-type=\ + |[exact:]resource-pool=\ + |[exact:]tag=\ + |[exact:]remote=\ + |[exact:]resource=id:", ) .schema(); @@ -102,65 +102,112 @@ impl ApiSectionDataEntry for ViewConfigEntry { } } +#[derive(Clone, Debug, PartialEq)] +/// Matcher for string-based values. +pub enum StringMatcher { + Exact(String), +} + +impl StringMatcher { + /// Check if a given string matches. + pub fn matches(&self, value: &str) -> bool { + match self { + StringMatcher::Exact(matched_value) => value == matched_value, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +/// Matcher for enum-based values. +pub struct EnumMatcher(pub T); + +impl EnumMatcher { + /// Check if a given value matches. + pub fn matches(&self, value: &T) -> bool { + self.0 == *value + } +} + #[derive(Clone, Debug, PartialEq)] /// Filter rule for includes/excludes. pub enum FilterRule { /// Match a resource type. - ResourceType(ResourceType), + ResourceType(EnumMatcher), /// Match a resource pools (for PVE guests). - ResourcePool(String), + ResourcePool(StringMatcher), /// Match a (global) resource ID, e.g. 'remote//guest/'. - ResourceId(String), + ResourceId(StringMatcher), /// Match a tag (for PVE guests). - Tag(String), + Tag(StringMatcher), /// Match a remote. - Remote(String), + Remote(StringMatcher), } impl FromStr for FilterRule { type Err = anyhow::Error; fn from_str(s: &str) -> Result { - Ok(match s.split_once(':') { - Some(("resource-type", value)) => FilterRule::ResourceType(value.parse()?), - Some(("resource-pool", value)) => { - if !PROXMOX_SAFE_ID_REGEX.is_match(value) { - bail!("invalid resource-pool value: {value}"); - } - FilterRule::ResourcePool(value.to_string()) - } - Some(("resource-id", value)) => { - if !GLOBAL_RESOURCE_ID_REGEX.is_match(value) { - bail!("invalid resource-id value: {value}"); - } - - FilterRule::ResourceId(value.to_string()) - } - Some(("tag", value)) => { - if !PROXMOX_SAFE_ID_REGEX.is_match(value) { - bail!("invalid tag value: {value}"); - } - FilterRule::Tag(value.to_string()) - } - Some(("remote", value)) => { - let _ = REMOTE_ID_SCHEMA.parse_simple_value(value)?; - FilterRule::Remote(value.to_string()) - } - Some((ty, _)) => bail!("invalid type: {ty}"), - None => bail!("invalid filter rule: {s}"), - }) + if let Some(s) = s.strip_prefix("exact:") { + parse_filter_rule(s) + } else { + parse_filter_rule(s) + } } } +fn parse_filter_rule(s: &str) -> Result { + Ok(match s.split_once('=') { + Some(("resource-type", value)) => FilterRule::ResourceType(EnumMatcher(value.parse()?)), + Some(("resource-pool", value)) => { + if !PROXMOX_SAFE_ID_REGEX.is_match(value) { + bail!("invalid resource-pool value: {value}"); + } + + let val = StringMatcher::Exact(value.into()); + FilterRule::ResourcePool(val) + } + Some(("resource-id", value)) => { + if !GLOBAL_RESOURCE_ID_REGEX.is_match(value) { + bail!("invalid resource-id value: {value}"); + } + + let val = StringMatcher::Exact(value.into()); + FilterRule::ResourceId(val) + } + Some(("tag", value)) => { + if !PROXMOX_SAFE_ID_REGEX.is_match(value) { + bail!("invalid tag value: {value}"); + } + let val = StringMatcher::Exact(value.into()); + FilterRule::Tag(val) + } + Some(("remote", value)) => { + if !PROXMOX_SAFE_ID_REGEX.is_match(value) { + let _ = REMOTE_ID_SCHEMA.parse_simple_value(value)?; + } + let val = StringMatcher::Exact(value.into()); + FilterRule::Remote(val) + } + Some((ty, _)) => bail!("invalid type: {ty}"), + None => bail!("invalid filter rule: {s}"), + }) +} + // used for serializing below, caution! impl Display for FilterRule { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - FilterRule::ResourceType(resource_type) => write!(f, "resource-type:{resource_type}"), - FilterRule::ResourcePool(pool) => write!(f, "resource-pool:{pool}"), - FilterRule::ResourceId(id) => write!(f, "resource-id:{id}"), - FilterRule::Tag(tag) => write!(f, "tag:{tag}"), - FilterRule::Remote(remote) => write!(f, "remote:{remote}"), + FilterRule::ResourceType(EnumMatcher(resource_type)) => { + write!(f, "exact:resource-type={resource_type}") + } + FilterRule::ResourceId(StringMatcher::Exact(value)) => { + write!(f, "exact:resource-id={value}") + } + FilterRule::Tag(StringMatcher::Exact(value)) => write!(f, "exact:tag={value}"), + FilterRule::Remote(StringMatcher::Exact(value)) => write!(f, "exact:remote={value}"), + FilterRule::ResourcePool(StringMatcher::Exact(value)) => { + write!(f, "exact:resource-pool={value}") + } } } } @@ -176,7 +223,9 @@ fn verify_filter_rule(input: &str) -> Result<(), anyhow::Error> { mod test { use anyhow::Error; - use crate::views::FilterRule; + use proxmox_section_config::typed::ApiSectionDataEntry; + + use super::*; fn parse_and_check_display(input: &str) -> Result { let rule: FilterRule = input.parse()?; @@ -190,21 +239,49 @@ mod test { assert!(parse_and_check_display("abc:").is_err()); assert!(parse_and_check_display("resource-type:").is_err()); - assert!(parse_and_check_display("resource-type:lxc").unwrap()); - assert!(parse_and_check_display("resource-type:qemu").unwrap()); - assert!(parse_and_check_display("resource-type:abc").is_err()); + assert!(parse_and_check_display("exact:resource-type=lxc").unwrap()); + assert!(parse_and_check_display("exact:resource-type=qemu").unwrap()); + assert!(parse_and_check_display("exact:resource-type=abc").is_err()); assert!(parse_and_check_display("resource-pool:").is_err()); - assert!(parse_and_check_display("resource-pool:somepool").unwrap()); + assert!(parse_and_check_display("exact:resource-pool=somepool").unwrap()); assert!(parse_and_check_display("resource-id:").is_err()); - assert!(parse_and_check_display("resource-id:remote/someremote/guest/100").unwrap()); - assert!(parse_and_check_display("resource-id:remote").is_err()); + assert!(parse_and_check_display("exact:resource-id=remote/someremote/guest/100").unwrap()); + assert!(parse_and_check_display("exact:resource-id=remote").is_err()); assert!(parse_and_check_display("tag:").is_err()); - assert!(parse_and_check_display("tag:sometag").unwrap()); + assert!(parse_and_check_display("exact:tag=sometag").unwrap()); - assert!(parse_and_check_display("remote:someremote").unwrap()); + assert!(parse_and_check_display("exact:remote=someremote").unwrap()); assert!(parse_and_check_display("remote:a").is_err()); } + + #[test] + fn config_smoke_test() { + let config = " +view: some-view + include exact:remote=someremote + include remote=someremote + include resource-type=qemu + include exact:resource-type=qemu + include resource-id=remote/someremote/guest/100 + include exact:resource-id=remote/someremote/guest/100 + include tag=sometag + include exact:tag=sometag + include resource-pool=somepool + include exact:resource-pool=somepool + exclude remote=someremote + exclude exact:remote=someremote + exclude resource-type=qemu + exclude exact:resource-type=qemu + exclude resource-id=remote/someremote/guest/100 + exclude exact:resource-id=remote/someremote/guest/100 + exclude tag=sometag + exclude exact:tag=sometag + exclude resource-pool=somepool + exclude exact:resource-pool=somepool +"; + ViewConfigEntry::parse_section_config("views.cfg", config).unwrap(); + } } diff --git a/server/src/views/mod.rs b/server/src/views/mod.rs index 80f8425c..8b4c70a4 100644 --- a/server/src/views/mod.rs +++ b/server/src/views/mod.rs @@ -72,7 +72,7 @@ impl View { for include in &self.config.include { if let FilterRule::Remote(r) = include { has_any_include_remote = true; - if r == remote { + if r.matches(remote) { matches_any_include_remote = true; break; } @@ -145,7 +145,7 @@ impl View { fn matches_remote_rule(remote: &str, rule: &FilterRule) -> bool { if let FilterRule::Remote(r) = rule { - r == remote + r.matches(remote) } else { false } @@ -154,17 +154,23 @@ impl View { fn check_rules(rules: &[FilterRule], remote: &str, resource: &ResourceData) -> bool { rules.iter().any(|rule| match rule { - FilterRule::ResourceType(resource_type) => resource.resource_type == *resource_type, - FilterRule::ResourcePool(pool) => resource.resource_pool == Some(pool), - FilterRule::ResourceId(resource_id) => resource.resource_id == resource_id, - FilterRule::Tag(tag) => { - if let Some(resource_tags) = resource.tags { - resource_tags.contains(tag) + FilterRule::ResourceType(resource_type) => resource_type.matches(&resource.resource_type), + FilterRule::ResourcePool(pool) => { + if let Some(resource_pool) = resource.resource_pool { + pool.matches(resource_pool) } else { false } } - FilterRule::Remote(included_remote) => included_remote == remote, + FilterRule::ResourceId(resource_id) => resource_id.matches(resource.resource_id), + FilterRule::Tag(tag) => { + if let Some(resource_tags) = resource.tags { + resource_tags.iter().any(|t| tag.matches(t)) + } else { + false + } + } + FilterRule::Remote(included_remote) => included_remote.matches(remote), }) } diff --git a/server/src/views/tests.rs b/server/src/views/tests.rs index 030b7994..14d94ac9 100644 --- a/server/src/views/tests.rs +++ b/server/src/views/tests.rs @@ -1,6 +1,6 @@ use pdm_api_types::{ resource::{PveLxcResource, PveQemuResource, PveStorageResource, Resource, ResourceType}, - views::{FilterRule, ViewConfig}, + views::{EnumMatcher, FilterRule, StringMatcher, ViewConfig}, }; use super::View; @@ -88,8 +88,8 @@ fn include_remotes() { let config = ViewConfig { id: "only-includes".into(), include: vec![ - FilterRule::Remote("remote-a".into()), - FilterRule::Remote("remote-b".into()), + FilterRule::Remote(StringMatcher::Exact("remote-a".into())), + FilterRule::Remote(StringMatcher::Exact("remote-b".into())), ], ..Default::default() }; @@ -132,8 +132,8 @@ fn exclude_remotes() { let config = ViewConfig { id: "only-excludes".into(), exclude: vec![ - FilterRule::Remote("remote-a".into()), - FilterRule::Remote("remote-b".into()), + FilterRule::Remote(StringMatcher::Exact("remote-a".into())), + FilterRule::Remote(StringMatcher::Exact("remote-b".into())), ], ..Default::default() }; @@ -177,12 +177,12 @@ fn include_exclude_remotes() { let config = ViewConfig { id: "both".into(), include: vec![ - FilterRule::Remote("remote-a".into()), - FilterRule::Remote("remote-b".into()), + FilterRule::Remote(StringMatcher::Exact("remote-a".into())), + FilterRule::Remote(StringMatcher::Exact("remote-b".into())), ], exclude: vec![ - FilterRule::Remote("remote-b".into()), - FilterRule::Remote("remote-c".into()), + FilterRule::Remote(StringMatcher::Exact("remote-b".into())), + FilterRule::Remote(StringMatcher::Exact("remote-c".into())), ], }; run_test( @@ -270,8 +270,8 @@ fn include_type() { ViewConfig { id: "include-resource-type".into(), include: vec![ - FilterRule::ResourceType(ResourceType::PveStorage), - FilterRule::ResourceType(ResourceType::PveQemu), + FilterRule::ResourceType(EnumMatcher(ResourceType::PveStorage)), + FilterRule::ResourceType(EnumMatcher(ResourceType::PveQemu)), ], ..Default::default() }, @@ -298,8 +298,8 @@ fn exclude_type() { ViewConfig { id: "exclude-resource-type".into(), exclude: vec![ - FilterRule::ResourceType(ResourceType::PveStorage), - FilterRule::ResourceType(ResourceType::PveQemu), + FilterRule::ResourceType(EnumMatcher(ResourceType::PveStorage)), + FilterRule::ResourceType(EnumMatcher(ResourceType::PveQemu)), ], ..Default::default() }, @@ -325,8 +325,10 @@ fn include_exclude_type() { run_test( ViewConfig { id: "exclude-resource-type".into(), - include: vec![FilterRule::ResourceType(ResourceType::PveQemu)], - exclude: vec![FilterRule::ResourceType(ResourceType::PveStorage)], + include: vec![FilterRule::ResourceType(EnumMatcher(ResourceType::PveQemu))], + exclude: vec![FilterRule::ResourceType(EnumMatcher( + ResourceType::PveStorage, + ))], }, &[ ( @@ -351,10 +353,10 @@ fn include_exclude_tags() { ViewConfig { id: "include-tags".into(), include: vec![ - FilterRule::Tag("tag1".to_string()), - FilterRule::Tag("tag2".to_string()), + FilterRule::Tag(StringMatcher::Exact("tag1".to_string())), + FilterRule::Tag(StringMatcher::Exact("tag2".to_string())), ], - exclude: vec![FilterRule::Tag("tag3".to_string())], + exclude: vec![FilterRule::Tag(StringMatcher::Exact("tag3".to_string()))], }, &[ ( @@ -396,10 +398,12 @@ fn include_exclude_resource_pool() { ViewConfig { id: "pools".into(), include: vec![ - FilterRule::ResourcePool("pool1".to_string()), - FilterRule::ResourcePool("pool2".to_string()), + FilterRule::ResourcePool(StringMatcher::Exact("pool1".to_string())), + FilterRule::ResourcePool(StringMatcher::Exact("pool2".to_string())), ], - exclude: vec![FilterRule::ResourcePool("pool2".to_string())], + exclude: vec![FilterRule::ResourcePool(StringMatcher::Exact( + "pool2".to_string(), + ))], }, &[ ( @@ -441,13 +445,19 @@ fn include_exclude_resource_id() { ViewConfig { id: "resource-id".into(), include: vec![ - FilterRule::ResourceId(format!("remote/{REMOTE}/guest/100")), - FilterRule::ResourceId(format!("remote/{REMOTE}/storage/{NODE}/{STORAGE}")), + FilterRule::ResourceId(StringMatcher::Exact(format!("remote/{REMOTE}/guest/100"))), + FilterRule::ResourceId(StringMatcher::Exact(format!( + "remote/{REMOTE}/storage/{NODE}/{STORAGE}" + ))), ], exclude: vec![ - FilterRule::ResourceId(format!("remote/{REMOTE}/guest/101")), - FilterRule::ResourceId("remote/otherremote/guest/101".to_string()), - FilterRule::ResourceId(format!("remote/{REMOTE}/storage/{NODE}/otherstorage")), + FilterRule::ResourceId(StringMatcher::Exact(format!("remote/{REMOTE}/guest/101"))), + FilterRule::ResourceId(StringMatcher::Exact( + "remote/otherremote/guest/101".to_string(), + )), + FilterRule::ResourceId(StringMatcher::Exact(format!( + "remote/{REMOTE}/storage/{NODE}/otherstorage" + ))), ], }, &[ @@ -491,10 +501,14 @@ fn node_included() { id: "both".into(), include: vec![ - FilterRule::Remote("remote-a".to_string()), - FilterRule::ResourceId("remote/someremote/node/test".to_string()), + FilterRule::Remote(StringMatcher::Exact("remote-a".to_string())), + FilterRule::ResourceId(StringMatcher::Exact( + "remote/someremote/node/test".to_string(), + )), ], - exclude: vec![FilterRule::Remote("remote-b".to_string())], + exclude: vec![FilterRule::Remote(StringMatcher::Exact( + "remote-b".to_string(), + ))], }); assert!(view.is_node_included("remote-a", "somenode")); @@ -511,7 +525,9 @@ fn can_skip_remote_if_excluded() { let view = View::new(ViewConfig { id: "abc".into(), include: vec![], - exclude: vec![FilterRule::Remote("remote-b".to_string())], + exclude: vec![FilterRule::Remote(StringMatcher::Exact( + "remote-b".to_string(), + ))], }); assert!(!view.can_skip_remote("remote-a")); @@ -522,7 +538,9 @@ fn can_skip_remote_if_excluded() { fn can_skip_remote_if_included() { let view = View::new(ViewConfig { id: "abc".into(), - include: vec![FilterRule::Remote("remote-b".to_string())], + include: vec![FilterRule::Remote(StringMatcher::Exact( + "remote-b".to_string(), + ))], exclude: vec![], }); @@ -535,8 +553,10 @@ fn can_skip_remote_cannot_skip_if_any_other_include() { let view = View::new(ViewConfig { id: "abc".into(), include: vec![ - FilterRule::Remote("remote-b".to_string()), - FilterRule::ResourceId("resource/remote-a/guest/100".to_string()), + FilterRule::Remote(StringMatcher::Exact("remote-b".to_string())), + FilterRule::ResourceId(StringMatcher::Exact( + "resource/remote-a/guest/100".to_string(), + )), ], exclude: vec![], }); @@ -549,10 +569,12 @@ fn can_skip_remote_cannot_skip_if_any_other_include() { fn can_skip_remote_explicit_remote_exclude() { let view = View::new(ViewConfig { id: "abc".into(), - include: vec![FilterRule::ResourceId( + include: vec![FilterRule::ResourceId(StringMatcher::Exact( "resource/remote-a/guest/100".to_string(), - )], - exclude: vec![FilterRule::Remote("remote-a".to_string())], + ))], + exclude: vec![FilterRule::Remote(StringMatcher::Exact( + "remote-a".to_string(), + ))], }); assert!(view.can_skip_remote("remote-a")); @@ -574,9 +596,9 @@ fn can_skip_remote_with_empty_config() { fn can_skip_remote_with_no_remote_includes() { let view = View::new(ViewConfig { id: "abc".into(), - include: vec![FilterRule::ResourceId( + include: vec![FilterRule::ResourceId(StringMatcher::Exact( "resource/remote-a/guest/100".to_string(), - )], + ))], exclude: vec![], }); @@ -588,7 +610,9 @@ fn can_skip_remote_with_no_remote_includes() { fn explicitly_included_remote() { let view = View::new(ViewConfig { id: "abc".into(), - include: vec![FilterRule::Remote("remote-b".to_string())], + include: vec![FilterRule::Remote(StringMatcher::Exact( + "remote-b".to_string(), + ))], exclude: vec![], }); @@ -599,8 +623,12 @@ fn explicitly_included_remote() { fn included_and_excluded_same_remote() { let view = View::new(ViewConfig { id: "abc".into(), - include: vec![FilterRule::Remote("remote-b".to_string())], - exclude: vec![FilterRule::Remote("remote-b".to_string())], + include: vec![FilterRule::Remote(StringMatcher::Exact( + "remote-b".to_string(), + ))], + exclude: vec![FilterRule::Remote(StringMatcher::Exact( + "remote-b".to_string(), + ))], }); assert!(!view.is_remote_explicitly_included("remote-b")); -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel