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 510B21FF165 for ; Thu, 6 Nov 2025 14:43:55 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 5F88817800; Thu, 6 Nov 2025 14:44:36 +0100 (CET) From: Lukas Wagner To: pdm-devel@lists.proxmox.com Date: Thu, 6 Nov 2025 14:43:46 +0100 Message-ID: <20251106134353.263598-5-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251106134353.263598-1-l.wagner@proxmox.com> References: <20251106134353.263598-1-l.wagner@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1762436618486 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.029 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 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 v3 04/11] views: add implementation for view resource filtering 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" This commit adds the resource filter implementation for the previously defined ViewConfig type. There are include/exclude rules for the following properties: - (global) resource-id - resource pool - resource type - remote - tags The rules are interpreted as follows: - no rules: everything matches - only includes: included resources match - only excluded: everything *but* the excluded resources match - include and exclude: excludes are applied *after* includes, meaning if one has a `include-remote foo` and `exclude-remote foo` at the same time, the remote `foo` will never match Some experiments were performed with a cache that works roughly as following: - HashMap> in a mutex - Cache invalidated if view config digest changed - Cache invalidated if certain resource fields such as tags or resource pools change from the last time (also with a digest-based implementation) Experimented with the `fake-remote` feature and and 15000 guests showed that caching was only faster than direct evaluation if the number of rules in the ViewConfig is *huge* (e.g. >1000 `include-resource-id` entries). But even for those, direct evaluation was always plenty fast, with evaluation times ~20ms for *all* resources. -> for any *realistic* view config, we should be good with direct evaluation, as long as we don't add any filter rules which are very expensive to evaluate. Signed-off-by: Lukas Wagner --- Notes: Changes since v2: - add is_remote_explicitly_included helper - merge commit that added tests into this one - simply `check_rules` using .any on the rules vec - add get_optional_view helper - rename ViewFilter* -> View* server/src/lib.rs | 1 + server/src/views/mod.rs | 205 +++++++++++++ server/src/views/tests.rs | 619 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 825 insertions(+) create mode 100644 server/src/views/mod.rs create mode 100644 server/src/views/tests.rs diff --git a/server/src/lib.rs b/server/src/lib.rs index 964807eb..0f25aa71 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -12,6 +12,7 @@ pub mod remote_tasks; pub mod remote_updates; pub mod resource_cache; pub mod task_utils; +pub mod views; pub mod connection; pub mod pbs_client; diff --git a/server/src/views/mod.rs b/server/src/views/mod.rs new file mode 100644 index 00000000..b5d79e0e --- /dev/null +++ b/server/src/views/mod.rs @@ -0,0 +1,205 @@ +use anyhow::{format_err, Error}; + +use pdm_api_types::{ + resource::{Resource, ResourceType}, + views::{FilterRule, ViewConfig, ViewConfigEntry}, +}; + +#[cfg(test)] +mod tests; + +/// Get view with a given ID. +/// +/// Returns an error if the view configuration file could not be read, or +/// if the view with the provided ID does not exist. +pub fn get_view(view_id: &str) -> Result { + let config = pdm_config::views::config()?; + + let entry = config + .get(view_id) + .cloned() + .ok_or_else(|| format_err!("unknown view: {view_id}"))?; + + match entry { + ViewConfigEntry::View(view_config) => Ok(View::new(view_config)), + } +} + +/// Get (optional) view with a given ID. +/// +/// Returns an error if the view configuration file could not be read, or +/// if the view with the provided ID does not exist. +pub fn get_optional_view(view_id: Option<&str>) -> Result, Error> { + view_id.map(get_view).transpose() +} + +/// View implementation. +/// +/// Given a [`ViewConfig`], this struct can be used to check if a resource/remote/node +/// matches the include/exclude rules. +#[derive(Clone)] +pub struct View { + config: ViewConfig, +} + +impl View { + /// Create a new [`View`]. + pub fn new(config: ViewConfig) -> Self { + Self { config } + } + + /// Check if a [`Resource`] matches the filter rules. + pub fn resource_matches(&self, remote: &str, resource: &Resource) -> bool { + // NOTE: Establishing a cache here is not worth the effort at the moment, evaluation of + // rules is *very* fast. + + let resource_data = resource.into(); + + self.check_if_included(remote, &resource_data) + && !self.check_if_excluded(remote, &resource_data) + } + + /// Check if a remote can be safely skipped based on the filter rule definition. + /// + /// When there are `include remote:<...>` or `exclude remote:<...>` rules, we can use these to + /// check if a remote needs to be considered at all. + pub fn can_skip_remote(&self, remote: &str) -> bool { + let mut has_any_include_remote = false; + let mut matches_any_include_remote = false; + + let mut any_other = false; + + for include in &self.config.include { + if let FilterRule::Remote(r) = include { + has_any_include_remote = true; + if r == remote { + matches_any_include_remote = true; + break; + } + } else { + any_other = true; + } + } + + let matches_any_exclude_remote = self + .config + .exclude + .iter() + .any(|rule| Self::matches_remote_rule(remote, rule)); + + (has_any_include_remote && !matches_any_include_remote && !any_other) + || matches_any_exclude_remote + } + + /// Check if a remote is *explicitly* included (and not excluded). + /// + /// A subset of the resources of a remote might still be pulled in by other rules, + /// but this function check if the remote as a whole is matched. + pub fn is_remote_explicitly_included(&self, remote: &str) -> bool { + let matches_include_remote = self + .config + .include + .iter() + .any(|rule| Self::matches_remote_rule(remote, rule)); + let matches_exclude_remote = self + .config + .exclude + .iter() + .any(|rule| Self::matches_remote_rule(remote, rule)); + + matches_include_remote && !matches_exclude_remote + } + + /// Check if a node is matched by the filter rules. + /// + /// This is equivalent to checking an actual node resource. + pub fn is_node_included(&self, remote: &str, node: &str) -> bool { + let resource_data = ResourceData { + resource_type: ResourceType::Node, + tags: None, + resource_pool: None, + resource_id: &format!("remote/{remote}/node/{node}"), + }; + + self.check_if_included(remote, &resource_data) + && !self.check_if_excluded(remote, &resource_data) + } + + /// Returns the name of the view. + pub fn name(&self) -> &str { + &self.config.id + } + + fn check_if_included(&self, remote: &str, resource: &ResourceData) -> bool { + if self.config.include.is_empty() { + // If there are no include rules, any resource is included (unless excluded) + return true; + } + + check_rules(&self.config.include, remote, resource) + } + + fn check_if_excluded(&self, remote: &str, resource: &ResourceData) -> bool { + check_rules(&self.config.exclude, remote, resource) + } + + fn matches_remote_rule(remote: &str, rule: &FilterRule) -> bool { + if let FilterRule::Remote(r) = rule { + r == remote + } else { + false + } + } +} + +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) + } else { + false + } + } + FilterRule::Remote(included_remote) => included_remote == remote, + }) +} + +struct ResourceData<'a> { + resource_type: ResourceType, + tags: Option<&'a [String]>, + resource_pool: Option<&'a String>, + resource_id: &'a str, +} + +impl<'a> From<&'a Resource> for ResourceData<'a> { + fn from(value: &'a Resource) -> Self { + match value { + Resource::PveQemu(resource) => ResourceData { + resource_type: value.resource_type(), + tags: Some(&resource.tags), + resource_pool: Some(&resource.pool), + resource_id: value.global_id(), + }, + Resource::PveLxc(resource) => ResourceData { + resource_type: value.resource_type(), + tags: Some(&resource.tags), + resource_pool: Some(&resource.pool), + resource_id: value.global_id(), + }, + Resource::PveNode(_) + | Resource::PveSdn(_) + | Resource::PbsNode(_) + | Resource::PbsDatastore(_) + | Resource::PveStorage(_) => ResourceData { + resource_type: value.resource_type(), + tags: None, + resource_pool: None, + resource_id: value.global_id(), + }, + } + } +} diff --git a/server/src/views/tests.rs b/server/src/views/tests.rs new file mode 100644 index 00000000..030b7994 --- /dev/null +++ b/server/src/views/tests.rs @@ -0,0 +1,619 @@ +use pdm_api_types::{ + resource::{PveLxcResource, PveQemuResource, PveStorageResource, Resource, ResourceType}, + views::{FilterRule, ViewConfig}, +}; + +use super::View; + +fn make_storage_resource(remote: &str, node: &str, storage_name: &str) -> Resource { + Resource::PveStorage(PveStorageResource { + disk: 1000, + maxdisk: 2000, + id: format!("remote/{remote}/storage/{node}/{storage_name}"), + storage: storage_name.into(), + node: node.into(), + status: "available".into(), + }) +} + +fn make_qemu_resource( + remote: &str, + node: &str, + vmid: u32, + pool: Option<&str>, + tags: &[&str], +) -> Resource { + Resource::PveQemu(PveQemuResource { + disk: 1000, + maxdisk: 2000, + id: format!("remote/{remote}/guest/{vmid}"), + node: node.into(), + status: "available".into(), + cpu: 0.0, + maxcpu: 0.0, + maxmem: 1024, + mem: 512, + name: format!("vm-{vmid}"), + // TODO: Check the API type - i guess it should be an option? + pool: pool.map_or_else(String::new, |a| a.into()), + tags: tags.iter().map(|tag| String::from(*tag)).collect(), + template: false, + uptime: 1337, + vmid, + }) +} + +fn make_lxc_resource( + remote: &str, + node: &str, + vmid: u32, + pool: Option<&str>, + tags: &[&str], +) -> Resource { + Resource::PveLxc(PveLxcResource { + disk: 1000, + maxdisk: 2000, + id: format!("remote/{remote}/guest/{vmid}"), + node: node.into(), + status: "available".into(), + cpu: 0.0, + maxcpu: 0.0, + maxmem: 1024, + mem: 512, + name: format!("vm-{vmid}"), + // TODO: Check the API type - i guess it should be an option? + pool: pool.map_or_else(String::new, |a| a.into()), + tags: tags.iter().map(|tag| String::from(*tag)).collect(), + template: false, + uptime: 1337, + vmid, + }) +} + +fn run_test(config: ViewConfig, tests: &[((&str, &Resource), bool)]) { + let filter = View::new(config); + + for ((remote_name, resource), expected) in tests { + eprintln!("remote: {remote_name}, resource: {resource:?}"); + assert_eq!(filter.resource_matches(remote_name, resource), *expected); + } +} + +const NODE: &str = "somenode"; +const STORAGE: &str = "somestorage"; +const REMOTE: &str = "someremote"; + +#[test] +fn include_remotes() { + let config = ViewConfig { + id: "only-includes".into(), + include: vec![ + FilterRule::Remote("remote-a".into()), + FilterRule::Remote("remote-b".into()), + ], + ..Default::default() + }; + run_test( + config.clone(), + &[ + ( + ( + "remote-a", + &make_storage_resource("remote-a", NODE, STORAGE), + ), + true, + ), + ( + ( + "remote-b", + &make_storage_resource("remote-b", NODE, STORAGE), + ), + true, + ), + ( + ( + "remote-c", + &make_storage_resource("remote-c", NODE, STORAGE), + ), + false, + ), + ], + ); + + let view = View::new(config); + + assert!(!view.can_skip_remote("remote-a")); + assert!(!view.can_skip_remote("remote-b")); + assert!(view.can_skip_remote("remote-c")); +} + +#[test] +fn exclude_remotes() { + let config = ViewConfig { + id: "only-excludes".into(), + exclude: vec![ + FilterRule::Remote("remote-a".into()), + FilterRule::Remote("remote-b".into()), + ], + ..Default::default() + }; + + run_test( + config.clone(), + &[ + ( + ( + "remote-a", + &make_storage_resource("remote-a", NODE, STORAGE), + ), + false, + ), + ( + ( + "remote-b", + &make_storage_resource("remote-b", NODE, STORAGE), + ), + false, + ), + ( + ( + "remote-c", + &make_storage_resource("remote-c", NODE, STORAGE), + ), + true, + ), + ], + ); + + let view = View::new(config); + + assert!(view.can_skip_remote("remote-a")); + assert!(view.can_skip_remote("remote-b")); + assert!(!view.can_skip_remote("remote-c")); +} + +#[test] +fn include_exclude_remotes() { + let config = ViewConfig { + id: "both".into(), + include: vec![ + FilterRule::Remote("remote-a".into()), + FilterRule::Remote("remote-b".into()), + ], + exclude: vec![ + FilterRule::Remote("remote-b".into()), + FilterRule::Remote("remote-c".into()), + ], + }; + run_test( + config.clone(), + &[ + ( + ( + "remote-a", + &make_storage_resource("remote-a", NODE, STORAGE), + ), + true, + ), + ( + ( + "remote-b", + &make_storage_resource("remote-b", NODE, STORAGE), + ), + false, + ), + ( + ( + "remote-c", + &make_storage_resource("remote-c", NODE, STORAGE), + ), + false, + ), + ], + ); + + let view = View::new(config); + + assert!(!view.can_skip_remote("remote-a")); + assert!(view.can_skip_remote("remote-b")); + assert!(view.can_skip_remote("remote-c")); + assert!(view.can_skip_remote("remote-d")); +} + +#[test] +fn empty_config() { + let config = ViewConfig { + id: "empty".into(), + ..Default::default() + }; + run_test( + config.clone(), + &[ + ( + ( + "remote-a", + &make_storage_resource("remote-a", NODE, STORAGE), + ), + true, + ), + ( + ( + "remote-b", + &make_storage_resource("remote-b", NODE, STORAGE), + ), + true, + ), + ( + ( + "remote-c", + &make_storage_resource("remote-c", NODE, STORAGE), + ), + true, + ), + ( + (REMOTE, &make_qemu_resource(REMOTE, NODE, 100, None, &[])), + true, + ), + ], + ); + + let view = View::new(config); + + assert!(!view.can_skip_remote("remote-a")); + assert!(!view.can_skip_remote("remote-b")); + assert!(!view.can_skip_remote("remote-c")); +} + +#[test] +fn include_type() { + run_test( + ViewConfig { + id: "include-resource-type".into(), + include: vec![ + FilterRule::ResourceType(ResourceType::PveStorage), + FilterRule::ResourceType(ResourceType::PveQemu), + ], + ..Default::default() + }, + &[ + ( + (REMOTE, &make_storage_resource(REMOTE, NODE, STORAGE)), + true, + ), + ( + (REMOTE, &make_qemu_resource(REMOTE, NODE, 100, None, &[])), + true, + ), + ( + (REMOTE, &make_lxc_resource(REMOTE, NODE, 101, None, &[])), + false, + ), + ], + ); +} + +#[test] +fn exclude_type() { + run_test( + ViewConfig { + id: "exclude-resource-type".into(), + exclude: vec![ + FilterRule::ResourceType(ResourceType::PveStorage), + FilterRule::ResourceType(ResourceType::PveQemu), + ], + ..Default::default() + }, + &[ + ( + (REMOTE, &make_storage_resource(REMOTE, NODE, STORAGE)), + false, + ), + ( + (REMOTE, &make_qemu_resource(REMOTE, NODE, 100, None, &[])), + false, + ), + ( + (REMOTE, &make_lxc_resource(REMOTE, NODE, 101, None, &[])), + true, + ), + ], + ); +} + +#[test] +fn include_exclude_type() { + run_test( + ViewConfig { + id: "exclude-resource-type".into(), + include: vec![FilterRule::ResourceType(ResourceType::PveQemu)], + exclude: vec![FilterRule::ResourceType(ResourceType::PveStorage)], + }, + &[ + ( + (REMOTE, &make_storage_resource(REMOTE, NODE, STORAGE)), + false, + ), + ( + (REMOTE, &make_qemu_resource(REMOTE, NODE, 100, None, &[])), + true, + ), + ( + (REMOTE, &make_lxc_resource(REMOTE, NODE, 101, None, &[])), + false, + ), + ], + ); +} + +#[test] +fn include_exclude_tags() { + run_test( + ViewConfig { + id: "include-tags".into(), + include: vec![ + FilterRule::Tag("tag1".to_string()), + FilterRule::Tag("tag2".to_string()), + ], + exclude: vec![FilterRule::Tag("tag3".to_string())], + }, + &[ + ( + (REMOTE, &make_storage_resource(REMOTE, NODE, STORAGE)), + // only qemu/lxc can match tags for now + false, + ), + ( + ( + REMOTE, + &make_qemu_resource(REMOTE, NODE, 100, None, &["tag1", "tag3"]), + ), + // because tag3 is excluded + false, + ), + ( + ( + REMOTE, + &make_lxc_resource(REMOTE, NODE, 101, None, &["tag1"]), + ), + // matches since it's in the includes + true, + ), + ( + ( + REMOTE, + &make_lxc_resource(REMOTE, NODE, 102, None, &["tag4"]), + ), + // Not in includes, can never match + false, + ), + ], + ); +} + +#[test] +fn include_exclude_resource_pool() { + run_test( + ViewConfig { + id: "pools".into(), + include: vec![ + FilterRule::ResourcePool("pool1".to_string()), + FilterRule::ResourcePool("pool2".to_string()), + ], + exclude: vec![FilterRule::ResourcePool("pool2".to_string())], + }, + &[ + ( + (REMOTE, &make_storage_resource(REMOTE, NODE, STORAGE)), + // only qemu/lxc can match pools for now + false, + ), + ( + ( + REMOTE, + &make_qemu_resource(REMOTE, NODE, 100, Some("pool2"), &[]), + ), + // because pool2 is excluded (takes precedence over includes) + false, + ), + ( + ( + REMOTE, + &make_lxc_resource(REMOTE, NODE, 101, Some("pool1"), &[]), + ), + // matches since it's in the includes + true, + ), + ( + ( + REMOTE, + &make_lxc_resource(REMOTE, NODE, 102, Some("pool4"), &[]), + ), + // Not in includes, can never match + false, + ), + ], + ); +} + +#[test] +fn include_exclude_resource_id() { + run_test( + ViewConfig { + id: "resource-id".into(), + include: vec![ + FilterRule::ResourceId(format!("remote/{REMOTE}/guest/100")), + FilterRule::ResourceId(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")), + ], + }, + &[ + ( + (REMOTE, &make_storage_resource(REMOTE, NODE, STORAGE)), + true, + ), + ( + (REMOTE, &make_qemu_resource(REMOTE, NODE, 100, None, &[])), + true, + ), + ( + (REMOTE, &make_lxc_resource(REMOTE, NODE, 101, None, &[])), + false, + ), + ( + (REMOTE, &make_lxc_resource(REMOTE, NODE, 102, None, &[])), + false, + ), + ( + ( + "otherremote", + &make_lxc_resource("otherremote", NODE, 101, None, &[]), + ), + false, + ), + ( + ( + "yetanoterremote", + &make_lxc_resource("yetanotherremote", NODE, 104, None, &[]), + ), + false, + ), + ], + ); +} + +#[test] +fn node_included() { + let view = View::new(ViewConfig { + id: "both".into(), + + include: vec![ + FilterRule::Remote("remote-a".to_string()), + FilterRule::ResourceId("remote/someremote/node/test".to_string()), + ], + exclude: vec![FilterRule::Remote("remote-b".to_string())], + }); + + assert!(view.is_node_included("remote-a", "somenode")); + assert!(view.is_node_included("remote-a", "somenode2")); + assert!(!view.is_node_included("remote-b", "somenode")); + assert!(!view.is_node_included("remote-b", "somenode2")); + assert!(view.is_node_included("someremote", "test")); + + assert_eq!(view.name(), "both"); +} + +#[test] +fn can_skip_remote_if_excluded() { + let view = View::new(ViewConfig { + id: "abc".into(), + include: vec![], + exclude: vec![FilterRule::Remote("remote-b".to_string())], + }); + + assert!(!view.can_skip_remote("remote-a")); + assert!(view.can_skip_remote("remote-b")); +} + +#[test] +fn can_skip_remote_if_included() { + let view = View::new(ViewConfig { + id: "abc".into(), + include: vec![FilterRule::Remote("remote-b".to_string())], + exclude: vec![], + }); + + assert!(!view.can_skip_remote("remote-b")); + assert!(view.can_skip_remote("remote-a")); +} + +#[test] +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()), + ], + exclude: vec![], + }); + + assert!(!view.can_skip_remote("remote-b")); + assert!(!view.can_skip_remote("remote-a")); +} + +#[test] +fn can_skip_remote_explicit_remote_exclude() { + let view = View::new(ViewConfig { + id: "abc".into(), + include: vec![FilterRule::ResourceId( + "resource/remote-a/guest/100".to_string(), + )], + exclude: vec![FilterRule::Remote("remote-a".to_string())], + }); + + assert!(view.can_skip_remote("remote-a")); +} + +#[test] +fn can_skip_remote_with_empty_config() { + let view = View::new(ViewConfig { + id: "abc".into(), + include: vec![], + exclude: vec![], + }); + + assert!(!view.can_skip_remote("remote-a")); + assert!(!view.can_skip_remote("remote-b")); +} + +#[test] +fn can_skip_remote_with_no_remote_includes() { + let view = View::new(ViewConfig { + id: "abc".into(), + include: vec![FilterRule::ResourceId( + "resource/remote-a/guest/100".to_string(), + )], + exclude: vec![], + }); + + assert!(!view.can_skip_remote("remote-a")); + assert!(!view.can_skip_remote("remote-b")); +} + +#[test] +fn explicitly_included_remote() { + let view = View::new(ViewConfig { + id: "abc".into(), + include: vec![FilterRule::Remote("remote-b".to_string())], + exclude: vec![], + }); + + assert!(view.is_remote_explicitly_included("remote-b")); +} + +#[test] +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())], + }); + + assert!(!view.is_remote_explicitly_included("remote-b")); +} + +#[test] +fn not_explicitly_included_remote() { + let view = View::new(ViewConfig { + id: "abc".into(), + include: vec![], + exclude: vec![], + }); + + // Assert that is not *explicitly* included + 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