From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 67D2D1FF0A8 for ; Thu, 20 Aug 2026 16:53:04 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id D54D42161B; Thu, 20 Aug 2026 16:52:45 +0200 (CEST) From: Lukas Wagner To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v2 14/20] tests: add helpers for building API-handler-level integration tests Date: Thu, 20 Aug 2026 16:52:14 +0200 Message-ID: <20260820145220.418032-15-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260820145220.418032-1-l.wagner@proxmox.com> References: <20260820145220.418032-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1787237519564 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.699 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) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust 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: RJKKZJ44CTD7VS56XR4XCXHPGJ6MRME4 X-Message-ID-Hash: RJKKZJ44CTD7VS56XR4XCXHPGJ6MRME4 X-MailFrom: l.wagner@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 Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Unfortunately, some subsystems still require static initialization, namely access control and worker tasks. For those we need a setup function that is ensure to be only called once in each test binary. Worker tasks especially require a directory in which it can place task logs. For this we create a temporary directory, which we clean up using libc::atexit. Unfortunately, tempfile::TempDir does not work for this case, since we'd need to put it in a static variable, and `drop` is never called for those. TestApplication is essentially a builder that can be used to produce a PdmApplication suitable for tests. Signed-off-by: Lukas Wagner --- Notes: Changes since v1: - don't .unwrap() in read_captured_responses server/tests/common/environment.rs | 25 +++ server/tests/common/mod.rs | 62 +++++ server/tests/common/test_application.rs | 287 ++++++++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 server/tests/common/environment.rs create mode 100644 server/tests/common/mod.rs create mode 100644 server/tests/common/test_application.rs diff --git a/server/tests/common/environment.rs b/server/tests/common/environment.rs new file mode 100644 index 00000000..5dad7b21 --- /dev/null +++ b/server/tests/common/environment.rs @@ -0,0 +1,25 @@ +use proxmox_router::RpcEnvironment; + +pub struct TestRpcEnvironment; + +impl RpcEnvironment for TestRpcEnvironment { + fn result_attrib_mut(&mut self) -> &mut serde_json::Value { + unimplemented!() + } + + fn result_attrib(&self) -> &serde_json::Value { + unimplemented!() + } + + fn env_type(&self) -> proxmox_router::RpcEnvironmentType { + unimplemented!() + } + + fn set_auth_id(&mut self, _user: Option) { + unimplemented!() + } + + fn get_auth_id(&self) -> Option { + Some("root@pam".to_string()) + } +} diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs new file mode 100644 index 00000000..07a07208 --- /dev/null +++ b/server/tests/common/mod.rs @@ -0,0 +1,62 @@ +use std::{ + path::{Path, PathBuf}, + sync::{Once, OnceLock}, +}; + +use anyhow::Context; +use serde::de::DeserializeOwned; + +use proxmox_sys::fs::CreateOptions; + +mod environment; +mod test_application; + +pub use environment::TestRpcEnvironment; +pub use test_application::*; + +/// Read and deserialize a previously captured API response. +pub async fn read_captured_response>( + path: P, +) -> Result { + let s = tokio::fs::read_to_string(path.as_ref()) + .await + .with_context(|| format!("could not read from {path}", path = path.as_ref().display())) + .map_err(proxmox_client::Error::Anyhow)?; + + serde_json::from_str(&s).map_err(|e| proxmox_client::Error::Anyhow(e.into())) +} + +static STATIC_TEMP_DIR: OnceLock = OnceLock::new(); + +extern "C" fn cleanup() { + if let Some(dir) = STATIC_TEMP_DIR.get() { + let _ = std::fs::remove_dir_all(dir); + } +} + +/// Run commonly-used setup steps that must only run *once*. +/// +/// Be sure to call this at the start of every test. +pub fn test_setup() { + static INIT: Once = Once::new(); + + INIT.call_once(|| { + let file_opts = CreateOptions::new(); + + let dir = proxmox_sys::fs::make_tmp_dir("/tmp", None).unwrap(); + STATIC_TEMP_DIR.set(dir.clone()).unwrap(); + + proxmox_rest_server::init_worker_tasks(dir.clone(), file_opts).unwrap(); + proxmox_access_control::init::init(&pdm_api_types::AccessControlConfig, dir) + .expect("failed to setup access control config"); + + unsafe { + libc::atexit(cleanup); + } + }); +} + +/// Create a [`TestRpcEnvironment`] for the use in a test. +pub fn rpcenv() -> TestRpcEnvironment { + TestRpcEnvironment +} diff --git a/server/tests/common/test_application.rs b/server/tests/common/test_application.rs new file mode 100644 index 00000000..8d21b261 --- /dev/null +++ b/server/tests/common/test_application.rs @@ -0,0 +1,287 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::{Context, Error, bail}; +use serde::{Serialize, de::DeserializeOwned}; + +use nix::unistd::User; +use proxmox_client::Client; +use proxmox_section_config::typed::SectionConfigData; + +use pbs_api_types::Authid; +use pdm_api_types::{ + ConfigDigest, + remotes::{Remote, RemoteType}, +}; + +use pdm_config::remotes::RemoteConfig; + +use server::context::product_config::ProductConfig; +use server::{ + connection::{ClientFactory, PveClient}, + context::ContextFactory, + pbs_client::PbsClient, +}; + +/// Shared, clonable storage for arbitrary data recorded by a [`PveTestRemote`]'s client +/// implementation. +#[derive(Clone, Default)] +pub struct TestRemoteState(Arc>>); + +impl TestRemoteState { + /// Record `value` under `key`, overwriting any previous value stored there. + pub fn set(&self, key: impl Into, value: &T) { + let value = serde_json::to_value(value).expect("failed to serialize test state value"); + self.0.lock().unwrap().insert(key.into(), value); + } + + /// Retrieve the value previously stored under `key`, if any. + pub fn get(&self, key: &str) -> Option { + self.0.lock().unwrap().get(key).cloned().map(|value| { + serde_json::from_value(value).expect("failed to deserialize test state value") + }) + } +} + +/// A PVE remote registered in a [`TestApplication`], passed to the client factory whenever a +/// client for it is created. +#[derive(Clone)] +pub struct PveTestRemote { + pub name: String, + pub nodes: u32, + pub state: TestRemoteState, +} + +#[derive(Clone)] +struct PveTestRemoteWithClientMaker { + remote: PveTestRemote, + make_client: Arc Result, Error> + Send + Sync>, +} + +impl PveTestRemote { + /// Build the [`Remote`] configuration entry describing this test remote. + pub fn to_remote(&self) -> Remote { + Remote { + ty: RemoteType::Pve, + id: self.name.clone(), + nodes: Vec::new(), + authid: Authid::root_auth_id().clone(), + token: "".into(), + web_url: None, + } + } +} + +/// A [`ContextFactory`] implementation for tests, backed by mocked remotes and a temporary +/// directory for config, caches and runtime directories. +/// +/// Make sure to *not* drops this while running the test cast, otherwise the temporary directory +/// will be dropped. +#[derive(Clone)] +pub struct TestApplication { + pve_remotes: HashMap, + base_dir: Arc, +} + +impl Default for TestApplication { + fn default() -> Self { + Self::new() + } +} + +impl TestApplication { + /// Create a test application without any registered remotes. + pub fn new() -> Self { + Self { + pve_remotes: HashMap::new(), + base_dir: Arc::new(tempfile::tempdir().expect("could not create temporary directory")), + } + } + + /// Register a PVE remote called `name`, using `f` to create its client implementation. + /// + /// The client is constructed for every request, so any data that should outlive a single + /// client has to be kept in `state`. + pub fn with_pve_remote(mut self, name: S, state: TestRemoteState, f: F) -> Self + where + S: Into, + C: pve_api_types::client::PveClient + Send + Sync + 'static, + F: Fn(PveTestRemote) -> C + Send + Sync + 'static, + { + let name = name.into(); + let make_client = Arc::new(move |remote| Ok(Arc::new(f(remote)) as Arc)); + + self.pve_remotes.insert( + name.clone(), + PveTestRemoteWithClientMaker { + make_client, + remote: PveTestRemote { + name, + nodes: 1, + state, + }, + }, + ); + + self + } +} + +impl ContextFactory for TestApplication { + fn make_client_factory(&self) -> Result, Error> { + Ok(Arc::new(self.clone())) + } + + fn make_remote_config(&self) -> Result, Error> { + Ok(Box::new(self.clone())) + } + + fn make_product_config(&self) -> Result { + let user = User::from_uid(nix::unistd::getuid()) + .ok() + .flatten() + .context("could not look up user")?; + + let base = self.base_dir.path(); + + let config_dir = base.join("config"); + let state_dir = base.join("state"); + let run_dir = base.join("run"); + let cache_dir = base.join("cache"); + + std::fs::create_dir(&config_dir)?; + std::fs::create_dir(&state_dir)?; + std::fs::create_dir(&run_dir)?; + std::fs::create_dir(&cache_dir)?; + + ProductConfig::builder() + .api_user(user.clone()) + .priv_user(user) + .config_dir(config_dir) + .state_dir(state_dir) + .run_dir(run_dir) + .cache_dir(cache_dir) + .build() + } + + // Override any other methods here if needed. +} + +impl RemoteConfig for TestApplication { + fn read(&self) -> Result<(SectionConfigData, ConfigDigest), anyhow::Error> { + let mut sections = SectionConfigData::default(); + + for pve_remote in self.pve_remotes.values() { + sections.insert( + pve_remote.remote.name.clone(), + pve_remote.remote.to_remote(), + ); + } + + Ok((sections, ConfigDigest::from_slice([]))) + } + + fn read_secret_token( + &self, + _remote: &pdm_api_types::remotes::Remote, + ) -> Result { + unimplemented!() + } + + fn lock(&self) -> Result { + unimplemented!() + } + + fn write( + &self, + _remotes: proxmox_section_config::typed::SectionConfigData, + ) -> Result<(), anyhow::Error> { + unimplemented!() + } +} + +#[async_trait::async_trait] +impl ClientFactory for TestApplication { + fn make_pve_client(&self, remote: &Remote) -> Result, Error> { + if let Some(a) = self.pve_remotes.get(&remote.id) { + (a.make_client)(a.remote.clone()) + } else { + bail!("remote not registered") + } + } + + fn make_pve_client_with_endpoint( + &self, + _remote: &Remote, + _target_endpoint: Option<&str>, + ) -> Result, Error> { + bail!("not implemented") + } + + fn make_pbs_client(&self, _remote: &Remote) -> Result, Error> { + bail!("not implemented") + } + + fn make_raw_client(&self, _remote: &Remote) -> Result, Error> { + bail!("not implemented") + } + + async fn make_pve_client_and_login(&self, _remote: &Remote) -> Result, Error> { + bail!("not implemented") + } + + async fn make_pbs_client_and_login( + &self, + _remote: &Remote, + ) -> Result>, Error> { + bail!("not implemented") + } +} + +macro_rules! test_pve_client { + ($ty:ident { $($overrides:tt)* }) => { + + pub struct $ty(crate::common::PveTestRemote); + + #[async_trait::async_trait] + impl pve_api_types::client::PveClient for $ty { + async fn list_nodes( + &self, + ) -> Result, proxmox_client::Error> { + let mut nodes = Vec::new(); + + for i in 0..self.0.nodes { + nodes.push(ClusterNodeIndexResponse { + cpu: Some(0.0), + level: None, + maxcpu: Some(4), + maxmem: Some(4096), + mem: Some(1000), + node: format!("{}-node-{i}", self.0.name), + ssl_fingerprint: None, + status: pve_api_types::ClusterNodeIndexResponseStatus::Online, + uptime: Some(1000), + }); + } + + Ok(nodes) + } + $($overrides)* + } + + impl $ty { + /// Name of the remote this client was created for. + pub fn remote(&self) -> &str { + &self.0.name + } + + /// Shared state of the remote this client was created for. + pub fn state(&self) -> crate::common::TestRemoteState { + self.0.state.clone() + } + } + }; +} + +pub(crate) use test_pve_client; -- 2.47.3