From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id E2C371FF13F for ; Thu, 07 May 2026 09:25:02 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C447AC4C8; Thu, 7 May 2026 09:25:02 +0200 (CEST) From: Thomas Lamprecht To: pdm-devel@lists.proxmox.com Subject: [PATCH 3/8] subscription: add key pool data model and config layer Date: Thu, 7 May 2026 09:17:26 +0200 Message-ID: <20260507072436.2649563-4-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260507072436.2649563-1-t.lamprecht@proxmox.com> References: <20260507072436.2649563-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1778138578053 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.003 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 Message-ID-Hash: GNCF2FXPHHC6EHHLIKDMMIY4LAX27AND X-Message-ID-Hash: GNCF2FXPHHC6EHHLIKDMMIY4LAX27AND X-MailFrom: t.lamprecht@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: Add a section-config-backed pool of subscription keys, each optionally pinned to a remote node. The schema accepts only PVE and PBS keys; other prefixes get rejected with a warning so a new SKU is noticed instead of silently falling through. Entries carry an origin marker, currently only a manual-entry variant, and the on-disk layout reserves a shadow file for the signed info blobs that a future shop-bundle import will populate. Init the subscription config on both the production and fake-remote build paths so test builds don't panic on first access. Signed-off-by: Thomas Lamprecht --- lib/pdm-api-types/Cargo.toml | 4 + lib/pdm-api-types/src/subscription.rs | 375 ++++++++++++++++++++++++- lib/pdm-api-types/tests/test_import.rs | 309 ++++++++++++++++++++ lib/pdm-config/src/lib.rs | 1 + lib/pdm-config/src/subscriptions.rs | 102 +++++++ server/src/context.rs | 7 + 6 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 lib/pdm-api-types/tests/test_import.rs create mode 100644 lib/pdm-config/src/subscriptions.rs diff --git a/lib/pdm-api-types/Cargo.toml b/lib/pdm-api-types/Cargo.toml index cb8b505..8282184 100644 --- a/lib/pdm-api-types/Cargo.toml +++ b/lib/pdm-api-types/Cargo.toml @@ -15,6 +15,7 @@ serde_plain.workspace = true serde_json.workspace = true proxmox-acme-api.workspace = true +proxmox-base64.workspace = true proxmox-access-control = { workspace = true, features = ["acl"] } proxmox-auth-api = { workspace = true, features = ["api-types"] } proxmox-apt-api-types.workspace = true @@ -32,3 +33,6 @@ proxmox-uuid = { workspace = true, features = ["serde"] } pbs-api-types = { workspace = true } pve-api-types = { workspace = true } + +[dev-dependencies] +serde_json.workspace = true diff --git a/lib/pdm-api-types/src/subscription.rs b/lib/pdm-api-types/src/subscription.rs index f0eb525..26ecfba 100644 --- a/lib/pdm-api-types/src/subscription.rs +++ b/lib/pdm-api-types/src/subscription.rs @@ -1,11 +1,16 @@ +use std::sync::OnceLock; use std::{collections::HashMap, str::FromStr}; use anyhow::Error; use serde::{Deserialize, Serialize}; -use proxmox_schema::api; +use proxmox_schema::{api, const_regex, ApiStringFormat, ApiType, Schema, StringSchema}; +use proxmox_section_config::typed::ApiSectionDataEntry; +use proxmox_section_config::{SectionConfig, SectionConfigPlugin}; use proxmox_subscription::{SubscriptionInfo, SubscriptionStatus}; +use crate::remotes::RemoteType; + #[api] // order is important here, since we use that for determining if a node has a valid subscription #[derive(Default, Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -174,3 +179,371 @@ pub struct PdmSubscriptionInfo { /// PDM subscription statistics pub statistics: SubscriptionStatistics, } + +const_regex! { + /// Subscription key pattern, restricted to the products PDM can drive. + /// + /// All keys follow `-<10 hex>`. PVE encodes the maximum CPU socket count between + /// the product letters and the level letter, for example `pve4b-1234567890`. PBS has no + /// socket count, so its keys look like `pbsc-1234567890`. Level letters are c/b/s/p + /// (Community/Basic/Standard/Premium). + /// + /// PMG and POM keys are not accepted yet: PDM has no remote-side handler for them. Widen + /// this regex and `ProductType::from_key` in lockstep when PDM grows support for them. + pub PRODUCT_KEY_REGEX = r"^(?:pve[0-9]+|pbs)[cbsp]-[0-9a-f]{10}$"; +} + +pub const PRODUCT_KEY_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&PRODUCT_KEY_REGEX); + +pub const SUBSCRIPTION_KEY_SCHEMA: Schema = StringSchema::new("Subscription key.") + .format(&PRODUCT_KEY_FORMAT) + .min_length(15) + .max_length(18) + .schema(); + +#[api] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +/// Proxmox product line a subscription key belongs to. +pub enum ProductType { + /// Proxmox Virtual Environment (PVE). + #[default] + Pve, + /// Proxmox Backup Server (PBS). + Pbs, + /// Proxmox Mail Gateway (PMG). + Pmg, + /// Proxmox Offline Mirror (POM). + Pom, +} + +impl ProductType { + /// Static string used as the section-config type marker on disk. + pub const fn as_section_type(self) -> &'static str { + match self { + ProductType::Pve => "pve", + ProductType::Pbs => "pbs", + ProductType::Pmg => "pmg", + ProductType::Pom => "pom", + } + } + + /// Classify a key by its prefix. + /// + /// Returns None when the prefix does not match any product PDM currently knows about; + /// callers should log that case so a new product line gets noticed instead of silently + /// sorted into a default bucket. + pub fn from_key(key: &str) -> Option { + let (prefix, _) = key.split_once('-')?; + if prefix.starts_with("pve") { + Some(ProductType::Pve) + } else if prefix.starts_with("pbs") { + Some(ProductType::Pbs) + } else if prefix.starts_with("pmg") { + Some(ProductType::Pmg) + } else if prefix.starts_with("pom") { + Some(ProductType::Pom) + } else { + None + } + } + + /// Whether PDM currently knows how to drive a remote of this product type. + /// + /// PDM only manages PVE and PBS remotes today, and the schema regex rejects everything else + /// at insert time. This method covers in-memory paths for forward-compat, for example + /// existing pool entries loaded after the regex is widened in a future release. + pub fn matches_remote_type(self, remote_type: RemoteType) -> bool { + matches!( + (self, remote_type), + (ProductType::Pve, RemoteType::Pve) | (ProductType::Pbs, RemoteType::Pbs) + ) + } +} + +impl std::fmt::Display for ProductType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_section_type()) + } +} + +/// Extract the socket count a PVE key covers (for example, 4 from "pve4b-..."). +/// +/// Returns None for non-PVE keys or unparseable prefixes. +pub fn socket_count_from_key(key: &str) -> Option { + let (prefix, _) = key.split_once('-')?; + if !prefix.starts_with("pve") { + return None; + } + let after_pve = &prefix[3..]; + let digits: String = after_pve + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + digits.parse().ok() +} + +/// Pick the candidate PVE key with the smallest socket count that still covers `node_sockets`. +/// +/// `candidates` yields `(id, key_string)` pairs. Keys without a parseable PVE socket count are +/// skipped, and keys covering fewer sockets than the node needs are filtered out. Returns the +/// id of the best fit, or None when no candidate covers the node. +pub fn pick_best_pve_socket_key<'a, I, K>(node_sockets: u32, candidates: I) -> Option +where + I: IntoIterator, +{ + candidates + .into_iter() + .filter_map(|(id, key)| socket_count_from_key(key).map(|s| (id, s))) + .filter(|(_, s)| *s >= node_sockets) + .min_by_key(|(_, s)| *s) + .map(|(id, _)| id) +} + +#[api] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +/// Origin of a subscription key entry. +pub enum SubscriptionKeySource { + /// Hand-entered into the pool by an admin. Used for any key added through the manual-entry + /// UI or CLI, and as the `serde(default)` for entries that predate this field. + #[default] + Manual, +} + +#[api( + properties: { + "key": { schema: SUBSCRIPTION_KEY_SCHEMA }, + "level": { optional: true }, + "status": { optional: true }, + "source": { optional: true }, + }, +)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +/// A subscription key entry in the PDM key pool. +pub struct SubscriptionKeyEntry { + /// The subscription key (for example, pve4b-1234567890). + pub key: String, + + /// Product type derived from the key prefix. + #[serde(rename = "product-type")] + pub product_type: ProductType, + + /// Subscription level, derived from the key suffix. + #[serde(default)] + pub level: SubscriptionLevel, + + /// Where the key entry came from. Defaults to manual entry. + #[serde(default)] + pub source: SubscriptionKeySource, + + /// Remote this key is assigned to (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub remote: Option, + + /// Node within the remote this key is assigned to (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub node: Option, + + /// Server ID this key is bound to (from signed info, if available). + #[serde(skip_serializing_if = "Option::is_none")] + pub serverid: Option, + + /// Subscription status from last check. + #[serde(default)] + pub status: SubscriptionStatus, + + /// Next due date. + #[serde(skip_serializing_if = "Option::is_none")] + pub nextduedate: Option, + + /// Product name. + #[serde(skip_serializing_if = "Option::is_none")] + pub productname: Option, + + /// Epoch of last import or refresh of this key's data. + #[serde(skip_serializing_if = "Option::is_none")] + pub checktime: Option, +} + +impl ApiSectionDataEntry for SubscriptionKeyEntry { + const INTERNALLY_TAGGED: Option<&'static str> = Some("product-type"); + const SECION_CONFIG_USES_TYPE_KEY: bool = true; + + fn section_config() -> &'static SectionConfig { + static CONFIG: OnceLock = OnceLock::new(); + + CONFIG.get_or_init(|| { + let mut this = + SectionConfig::new(&SUBSCRIPTION_KEY_SCHEMA).with_type_key("product-type"); + for ty in [ + ProductType::Pve, + ProductType::Pbs, + ProductType::Pmg, + ProductType::Pom, + ] { + this.register_plugin(SectionConfigPlugin::new( + ty.as_section_type().to_string(), + Some("key".to_string()), + SubscriptionKeyEntry::API_SCHEMA.unwrap_object_schema(), + )); + } + this + }) + } + + fn section_type(&self) -> &'static str { + self.product_type.as_section_type() + } +} + +#[api( + properties: { + "key": { schema: SUBSCRIPTION_KEY_SCHEMA }, + }, +)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +/// Shadow entry storing the signed subscription info blob for a key. +/// +/// Currently only populated by the future shop-bundle import flow; manually-added keys leave +/// this table empty. The data layer is in place so that adding the import path later does not +/// require reshaping the on-disk config. +pub struct SubscriptionKeyShadow { + /// The subscription key. + pub key: String, + + /// Product type (section type marker). + #[serde(rename = "product-type")] + pub product_type: ProductType, + + /// Base64-encoded signed SubscriptionInfo JSON. + #[serde(default)] + pub info: String, +} + +impl ApiSectionDataEntry for SubscriptionKeyShadow { + const INTERNALLY_TAGGED: Option<&'static str> = Some("product-type"); + const SECION_CONFIG_USES_TYPE_KEY: bool = true; + + fn section_config() -> &'static SectionConfig { + static CONFIG: OnceLock = OnceLock::new(); + + CONFIG.get_or_init(|| { + let mut this = + SectionConfig::new(&SUBSCRIPTION_KEY_SCHEMA).with_type_key("product-type"); + for ty in [ + ProductType::Pve, + ProductType::Pbs, + ProductType::Pmg, + ProductType::Pom, + ] { + this.register_plugin(SectionConfigPlugin::new( + ty.as_section_type().to_string(), + Some("key".to_string()), + SubscriptionKeyShadow::API_SCHEMA.unwrap_object_schema(), + )); + } + this + }) + } + + fn section_type(&self) -> &'static str { + self.product_type.as_section_type() + } +} + +/// Decode a base64-encoded `SubscriptionInfo` JSON blob from the shadow file. +/// +/// Forward-compat helper for the future shop-bundle import path. Returns the parsed +/// `SubscriptionInfo`; the caller is responsible for verifying the signature against the shop's +/// signing key. +pub fn parse_signed_info_blob(b64: &str) -> Result { + let bytes = proxmox_base64::decode(b64)?; + let info = serde_json::from_slice(&bytes)?; + Ok(info) +} + +/// Cross-check the `serverid` of a shadowed entry against what the remote reports. +/// +/// Forward-compat helper for the future bundle-import and push flow: when the shadow has a +/// signed serverid binding, the operator should be warned if the remote it is being pushed to +/// has a different hardware id. Returns Ok(None) when there is nothing to compare. +pub fn verify_serverid( + entry: &SubscriptionKeyEntry, + remote_info: &SubscriptionInfo, +) -> Result, Error> { + let Some(expected) = entry.serverid.as_deref() else { + return Ok(None); + }; + let Some(actual) = remote_info.serverid.as_deref() else { + return Ok(None); + }; + if expected == actual { + Ok(None) + } else { + Ok(Some(ServeridMismatch { + key: entry.key.clone(), + expected: expected.to_string(), + actual: actual.to_string(), + })) + } +} + +/// Result of [`verify_serverid`] when the bound and observed server-ids disagree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServeridMismatch { + pub key: String, + pub expected: String, + pub actual: String, +} + +#[api] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +/// Subscription status of a single remote node, combining remote query data with key pool +/// assignment information. +pub struct RemoteNodeStatus { + /// Remote name. + pub remote: String, + /// Remote type (pve or pbs). + #[serde(rename = "type")] + pub ty: RemoteType, + /// Node name. + pub node: String, + /// Number of CPU sockets (PVE only). + #[serde(skip_serializing_if = "Option::is_none")] + pub sockets: Option, + /// Current subscription status. + #[serde(default)] + pub status: SubscriptionStatus, + /// Subscription level. + #[serde(default)] + pub level: SubscriptionLevel, + /// Currently assigned key from the pool (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub assigned_key: Option, + /// Current key on the node (from remote query). + #[serde(skip_serializing_if = "Option::is_none")] + pub current_key: Option, +} + +#[api] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +/// A proposed key-to-node assignment from the auto-assign algorithm. +pub struct ProposedAssignment { + /// The subscription key to assign. + pub key: String, + /// Target remote. + pub remote: String, + /// Target node. + pub node: String, + /// Socket count of the key (PVE only). + #[serde(skip_serializing_if = "Option::is_none")] + pub key_sockets: Option, + /// Socket count of the node (PVE only). + #[serde(skip_serializing_if = "Option::is_none")] + pub node_sockets: Option, +} diff --git a/lib/pdm-api-types/tests/test_import.rs b/lib/pdm-api-types/tests/test_import.rs new file mode 100644 index 0000000..2bb1cd6 --- /dev/null +++ b/lib/pdm-api-types/tests/test_import.rs @@ -0,0 +1,309 @@ +//! SectionConfig round-trip and helper tests for the subscription key pool. +//! +//! Run with: cargo test -p pdm-api-types --test test_import + +use pdm_api_types::subscription::*; +use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData}; +use proxmox_subscription::SubscriptionStatus; + +#[test] +fn entry_roundtrip() { + let mut config = SectionConfigData::::default(); + + let entry = SubscriptionKeyEntry { + key: "pve4b-aa11bb2233".to_string(), + product_type: ProductType::Pve, + level: SubscriptionLevel::Basic, + source: SubscriptionKeySource::Manual, + remote: Some("my-cluster".to_string()), + node: Some("node1".to_string()), + serverid: Some("AABBCCDD".to_string()), + status: SubscriptionStatus::Active, + nextduedate: Some("2027-06-01".to_string()), + productname: Some("Proxmox VE Basic".to_string()), + checktime: Some(1700000000), + }; + + config.insert("pve4b-aa11bb2233".to_string(), entry); + + let raw = SubscriptionKeyEntry::write_section_config("test", &config).expect("write failed"); + let parsed = SubscriptionKeyEntry::parse_section_config("test", &raw).expect("parse failed"); + + let back = parsed.get("pve4b-aa11bb2233").expect("key not found"); + assert_eq!(back.key, "pve4b-aa11bb2233"); + assert_eq!(back.product_type, ProductType::Pve); + assert_eq!(back.source, SubscriptionKeySource::Manual); + assert_eq!(back.remote.as_deref(), Some("my-cluster")); + assert_eq!(back.node.as_deref(), Some("node1")); + assert_eq!(back.status, SubscriptionStatus::Active); + assert_eq!(back.nextduedate.as_deref(), Some("2027-06-01")); +} + +#[test] +fn shadow_roundtrip() { + let mut shadow = SectionConfigData::::default(); + + shadow.insert( + "pve4b-aa11bb2233".to_string(), + SubscriptionKeyShadow { + key: "pve4b-aa11bb2233".to_string(), + product_type: ProductType::Pve, + info: "dGVzdA==".to_string(), + }, + ); + + let raw = SubscriptionKeyShadow::write_section_config("test", &shadow).expect("write failed"); + let parsed = SubscriptionKeyShadow::parse_section_config("test", &raw).expect("parse failed"); + + let back = parsed.get("pve4b-aa11bb2233").expect("key not found"); + assert_eq!(back.info, "dGVzdA=="); +} + +#[test] +fn deserialize_api_response_json() { + let json = serde_json::json!({ + "key": "pve4b-aa11bb2233", + "nextduedate": "2027-06-01", + "product-type": "pve", + "productname": "Proxmox VE Basic", + "serverid": "AABBCCDD", + "status": "active" + }); + + let entry: SubscriptionKeyEntry = serde_json::from_value(json).unwrap(); + assert_eq!(entry.key, "pve4b-aa11bb2233"); + assert_eq!(entry.product_type, ProductType::Pve); + assert_eq!(entry.status, SubscriptionStatus::Active); + assert_eq!(entry.source, SubscriptionKeySource::Manual); +} + +#[test] +fn deserialize_without_optional_fields() { + let json = serde_json::json!({ + "key": "pbsb-ee77ff8899", + "product-type": "pbs", + }); + + let entry: SubscriptionKeyEntry = serde_json::from_value(json).unwrap(); + assert_eq!(entry.key, "pbsb-ee77ff8899"); + assert_eq!(entry.product_type, ProductType::Pbs); + assert!(entry.remote.is_none()); + assert!(entry.nextduedate.is_none()); +} + +#[test] +fn product_type_classification() { + let cases = [ + ("pve4b-1234567890", Some(ProductType::Pve), "pve"), + ("pbss-abcdef0123", Some(ProductType::Pbs), "pbs"), + ("pmgb-1234567890", Some(ProductType::Pmg), "pmg"), + ("pomb-1234567890", Some(ProductType::Pom), "pom"), + ("xxx-1234567890", None, ""), + ("no-dash", None, ""), + ]; + for (key, expected, marker) in cases { + assert_eq!(ProductType::from_key(key), expected, "from_key({key})"); + if let Some(pt) = expected { + assert_eq!(pt.as_section_type(), marker, "section_type for {key}"); + } + } +} + +#[test] +fn socket_count_extraction() { + assert_eq!(socket_count_from_key("pve1c-1234567890"), Some(1)); + assert_eq!(socket_count_from_key("pve2b-1234567890"), Some(2)); + assert_eq!(socket_count_from_key("pve4s-1234567890"), Some(4)); + assert_eq!(socket_count_from_key("pve8p-1234567890"), Some(8)); + assert_eq!(socket_count_from_key("pbss-1234567890"), None); + assert_eq!(socket_count_from_key("pvexb-1234567890"), None); +} + +#[test] +fn remote_type_matching() { + use pdm_api_types::remotes::RemoteType; + + assert!(ProductType::Pve.matches_remote_type(RemoteType::Pve)); + assert!(!ProductType::Pve.matches_remote_type(RemoteType::Pbs)); + assert!(ProductType::Pbs.matches_remote_type(RemoteType::Pbs)); + assert!(!ProductType::Pbs.matches_remote_type(RemoteType::Pve)); + // PMG and POM are reserved product types but PDM cannot manage those remotes yet. + assert!(!ProductType::Pmg.matches_remote_type(RemoteType::Pve)); + assert!(!ProductType::Pmg.matches_remote_type(RemoteType::Pbs)); + assert!(!ProductType::Pom.matches_remote_type(RemoteType::Pbs)); +} + +#[test] +fn subscription_level_from_key_suffix() { + assert_eq!( + SubscriptionLevel::from_key(Some("pve4c-123")), + SubscriptionLevel::Community + ); + assert_eq!( + SubscriptionLevel::from_key(Some("pve4b-123")), + SubscriptionLevel::Basic + ); + assert_eq!( + SubscriptionLevel::from_key(Some("pve4s-123")), + SubscriptionLevel::Standard + ); + assert_eq!( + SubscriptionLevel::from_key(Some("pve2p-123")), + SubscriptionLevel::Premium + ); + assert_eq!( + SubscriptionLevel::from_key(Some("pbsb-123")), + SubscriptionLevel::Basic + ); + assert_eq!(SubscriptionLevel::from_key(None), SubscriptionLevel::None); + assert_eq!( + SubscriptionLevel::from_key(Some("")), + SubscriptionLevel::None + ); +} + +#[test] +fn subscription_level_display_fromstr_roundtrip() { + for level in [ + SubscriptionLevel::None, + SubscriptionLevel::Community, + SubscriptionLevel::Basic, + SubscriptionLevel::Standard, + SubscriptionLevel::Premium, + SubscriptionLevel::Unknown, + ] { + let s = format!("{level}"); + let parsed: SubscriptionLevel = s.parse().unwrap(); + assert_eq!(parsed, level, "roundtrip failed for {s}"); + } + + // Backward compatibility: legacy single-letter wire format still parses. + for (letter, level) in [ + ("c", SubscriptionLevel::Community), + ("b", SubscriptionLevel::Basic), + ("s", SubscriptionLevel::Standard), + ("p", SubscriptionLevel::Premium), + ] { + assert_eq!(letter.parse::().unwrap(), level); + } +} + +#[test] +fn multiple_keys_different_types() { + let mut config = SectionConfigData::::default(); + + config.insert( + "pve4b-aaaa111111".to_string(), + SubscriptionKeyEntry { + key: "pve4b-aaaa111111".to_string(), + product_type: ProductType::Pve, + status: SubscriptionStatus::Active, + ..Default::default() + }, + ); + config.insert( + "pbss-bbbb222222".to_string(), + SubscriptionKeyEntry { + key: "pbss-bbbb222222".to_string(), + product_type: ProductType::Pbs, + status: SubscriptionStatus::Active, + ..Default::default() + }, + ); + + let raw = SubscriptionKeyEntry::write_section_config("test", &config).unwrap(); + let parsed = SubscriptionKeyEntry::parse_section_config("test", &raw).unwrap(); + + assert_eq!( + parsed.get("pve4b-aaaa111111").unwrap().product_type, + ProductType::Pve + ); + assert_eq!( + parsed.get("pbss-bbbb222222").unwrap().product_type, + ProductType::Pbs + ); +} + +#[test] +fn pick_best_pve_socket_key_edge_cases() { + let pool = [ + ("pve1c-aaa", "pve1c-aaa"), + ("pve2b-bbb", "pve2b-bbb"), + ("pve4s-ccc", "pve4s-ccc"), + ("pve8p-ddd", "pve8p-ddd"), + ]; + let pick = + |sockets: u32| pick_best_pve_socket_key(sockets, pool.iter().map(|(id, k)| (*id, *k))); + + // Exact match prefers the equally-sized key over a larger one. + assert_eq!(pick(2), Some("pve2b-bbb")); + + // No exact match: fall through to the smallest key that still covers the node. + assert_eq!(pick(3), Some("pve4s-ccc")); + assert_eq!(pick(5), Some("pve8p-ddd")); + + // Single-socket node still picks the single-socket key (does not overprovision). + assert_eq!(pick(1), Some("pve1c-aaa")); + + // Node larger than every key has no fit. + assert_eq!(pick(16), None); + + // Empty candidate list is None. + let empty: [(&str, &str); 0] = []; + assert_eq!( + pick_best_pve_socket_key(2, empty.iter().map(|(id, k)| (*id, *k))), + None, + ); + + // Non-PVE keys are skipped silently. + let mixed = [("a", "pbsc-aaaa111111"), ("b", "pve2b-bbbb222222")]; + assert_eq!( + pick_best_pve_socket_key(1, mixed.iter().map(|(id, k)| (*id, *k))), + Some("b"), + ); +} + +#[test] +fn schema_accepts_pve_pbs_only() { + use proxmox_schema::ApiType; + let schema = SubscriptionKeyEntry::API_SCHEMA.unwrap_object_schema(); + let key_schema = schema + .lookup("key") + .expect("key property in object schema") + .1; + assert!(key_schema.parse_simple_value("garbage").is_err()); + assert!(key_schema.parse_simple_value("xxx-yyyyyyyyyy").is_err()); + assert!(key_schema.parse_simple_value("pve4b-1234567890").is_ok()); + assert!(key_schema.parse_simple_value("pbss-abcdef0123").is_ok()); + // PMG and POM are not driven by PDM today, so the schema rejects them; widen the regex + // when remote-side support lands. + assert!(key_schema.parse_simple_value("pmgb-deadbeef00").is_err()); + assert!(key_schema.parse_simple_value("pomb-deadbeef00").is_err()); +} + +#[test] +fn verify_serverid_helper() { + let entry = SubscriptionKeyEntry { + key: "pve4b-aa11bb2233".to_string(), + product_type: ProductType::Pve, + serverid: Some("AABBCCDD".to_string()), + ..Default::default() + }; + + let mut info = proxmox_subscription::SubscriptionInfo::default(); + info.serverid = Some("AABBCCDD".to_string()); + assert_eq!(verify_serverid(&entry, &info).unwrap(), None); + + info.serverid = Some("DEADBEEF".to_string()); + let mismatch = verify_serverid(&entry, &info).unwrap().unwrap(); + assert_eq!(mismatch.expected, "AABBCCDD"); + assert_eq!(mismatch.actual, "DEADBEEF"); + + // entry without serverid -> nothing to verify + let entry = SubscriptionKeyEntry { + key: "pve4b-aa11bb2233".to_string(), + product_type: ProductType::Pve, + ..Default::default() + }; + assert_eq!(verify_serverid(&entry, &info).unwrap(), None); +} diff --git a/lib/pdm-config/src/lib.rs b/lib/pdm-config/src/lib.rs index 5b9bcca..46ad1a2 100644 --- a/lib/pdm-config/src/lib.rs +++ b/lib/pdm-config/src/lib.rs @@ -8,6 +8,7 @@ pub mod domains; pub mod node; pub mod remotes; pub mod setup; +pub mod subscriptions; pub mod views; mod config_version_cache; diff --git a/lib/pdm-config/src/subscriptions.rs b/lib/pdm-config/src/subscriptions.rs new file mode 100644 index 0000000..7e930ba --- /dev/null +++ b/lib/pdm-config/src/subscriptions.rs @@ -0,0 +1,102 @@ +//! Read/write subscription key pool configuration. +//! +//! Call [`init`] to inject a concrete `SubscriptionKeyConfig` instance before using the +//! module-level functions. +//! +//! The shadow-config functions stash signed `SubscriptionInfo` blobs alongside the plain key +//! entries, which is intended as future proofing for a more automated (shop) import without having +//! to adapt the data layer. + +use std::sync::OnceLock; + +use anyhow::Error; + +use proxmox_config_digest::ConfigDigest; +use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard}; +use proxmox_section_config::typed::{ApiSectionDataEntry, SectionConfigData}; + +use pdm_api_types::subscription::{SubscriptionKeyEntry, SubscriptionKeyShadow}; +use pdm_buildcfg::configdir; + +pub const SUBSCRIPTIONS_CFG_FILENAME: &str = configdir!("/subscriptions.cfg"); +const SUBSCRIPTIONS_SHADOW_FILENAME: &str = configdir!("/subscriptions.shadow"); +pub const SUBSCRIPTIONS_CFG_LOCKFILE: &str = configdir!("/.subscriptions.lock"); + +static INSTANCE: OnceLock> = OnceLock::new(); + +fn instance() -> &'static (dyn SubscriptionKeyConfig + Send + Sync) { + INSTANCE + .get() + .expect("subscription key config not initialized") + .as_ref() +} + +pub fn lock_config() -> Result { + instance().lock_config() +} + +pub fn config() -> Result<(SectionConfigData, ConfigDigest), Error> { + instance().config() +} + +pub fn shadow_config() -> Result, Error> { + instance().shadow_config() +} + +pub fn save_config(config: &SectionConfigData) -> Result<(), Error> { + instance().save_config(config) +} + +pub fn save_shadow(shadow: &SectionConfigData) -> Result<(), Error> { + instance().save_shadow(shadow) +} + +pub trait SubscriptionKeyConfig { + fn config(&self) -> Result<(SectionConfigData, ConfigDigest), Error>; + fn shadow_config(&self) -> Result, Error>; + fn lock_config(&self) -> Result; + fn save_config(&self, config: &SectionConfigData) -> Result<(), Error>; + fn save_shadow(&self, shadow: &SectionConfigData) -> Result<(), Error>; +} + +pub struct DefaultSubscriptionKeyConfig; + +impl SubscriptionKeyConfig for DefaultSubscriptionKeyConfig { + fn lock_config(&self) -> Result { + open_api_lockfile(SUBSCRIPTIONS_CFG_LOCKFILE, None, true) + } + + fn config(&self) -> Result<(SectionConfigData, ConfigDigest), Error> { + let content = proxmox_sys::fs::file_read_optional_string(SUBSCRIPTIONS_CFG_FILENAME)? + .unwrap_or_default(); + + let digest = openssl::sha::sha256(content.as_bytes()); + let data = + SubscriptionKeyEntry::parse_section_config(SUBSCRIPTIONS_CFG_FILENAME, &content)?; + + Ok((data, digest.into())) + } + + fn shadow_config(&self) -> Result, Error> { + let content = proxmox_sys::fs::file_read_optional_string(SUBSCRIPTIONS_SHADOW_FILENAME)? + .unwrap_or_default(); + SubscriptionKeyShadow::parse_section_config(SUBSCRIPTIONS_SHADOW_FILENAME, &content) + } + + fn save_config(&self, config: &SectionConfigData) -> Result<(), Error> { + let raw = SubscriptionKeyEntry::write_section_config(SUBSCRIPTIONS_CFG_FILENAME, config)?; + replace_config(SUBSCRIPTIONS_CFG_FILENAME, raw.as_bytes()) + } + + fn save_shadow(&self, shadow: &SectionConfigData) -> Result<(), Error> { + let raw = + SubscriptionKeyShadow::write_section_config(SUBSCRIPTIONS_SHADOW_FILENAME, shadow)?; + replace_config(SUBSCRIPTIONS_SHADOW_FILENAME, raw.as_bytes()) + } +} + +pub fn init(instance: Box) { + if INSTANCE.set(instance).is_err() { + panic!("subscription key config instance already set"); + } +} diff --git a/server/src/context.rs b/server/src/context.rs index c5da0af..a4afcdd 100644 --- a/server/src/context.rs +++ b/server/src/context.rs @@ -15,6 +15,13 @@ fn default_remote_setup() { /// Dependency-inject concrete implementations needed at runtime. pub fn init() -> Result<(), Error> { + // The subscription key pool is product-only (PDM stores its own pool of + // keys regardless of how remotes are mocked or not), so initialise it on + // both paths. + pdm_config::subscriptions::init(Box::new( + pdm_config::subscriptions::DefaultSubscriptionKeyConfig, + )); + #[cfg(remote_config = "faked")] { use anyhow::bail; -- 2.47.3