From: Thomas Lamprecht <t.lamprecht@proxmox.com>
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 [thread overview]
Message-ID: <20260507072436.2649563-4-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260507072436.2649563-1-t.lamprecht@proxmox.com>
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 <t.lamprecht@proxmox.com>
---
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 `<prefix>-<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<Self> {
+ 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<u32> {
+ 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<K>
+where
+ I: IntoIterator<Item = (K, &'a str)>,
+{
+ 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<String>,
+
+ /// Node within the remote this key is assigned to (if any).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub node: Option<String>,
+
+ /// Server ID this key is bound to (from signed info, if available).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub serverid: Option<String>,
+
+ /// Subscription status from last check.
+ #[serde(default)]
+ pub status: SubscriptionStatus,
+
+ /// Next due date.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub nextduedate: Option<String>,
+
+ /// Product name.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub productname: Option<String>,
+
+ /// Epoch of last import or refresh of this key's data.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub checktime: Option<i64>,
+}
+
+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<SectionConfig> = 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<SectionConfig> = 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<SubscriptionInfo, Error> {
+ 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<Option<ServeridMismatch>, 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<i64>,
+ /// 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<String>,
+ /// Current key on the node (from remote query).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub current_key: Option<String>,
+}
+
+#[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<u32>,
+ /// Socket count of the node (PVE only).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub node_sockets: Option<i64>,
+}
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::<SubscriptionKeyEntry>::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::<SubscriptionKeyShadow>::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::<SubscriptionLevel>().unwrap(), level);
+ }
+}
+
+#[test]
+fn multiple_keys_different_types() {
+ let mut config = SectionConfigData::<SubscriptionKeyEntry>::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<Box<dyn SubscriptionKeyConfig + Send + Sync>> = OnceLock::new();
+
+fn instance() -> &'static (dyn SubscriptionKeyConfig + Send + Sync) {
+ INSTANCE
+ .get()
+ .expect("subscription key config not initialized")
+ .as_ref()
+}
+
+pub fn lock_config() -> Result<ApiLockGuard, Error> {
+ instance().lock_config()
+}
+
+pub fn config() -> Result<(SectionConfigData<SubscriptionKeyEntry>, ConfigDigest), Error> {
+ instance().config()
+}
+
+pub fn shadow_config() -> Result<SectionConfigData<SubscriptionKeyShadow>, Error> {
+ instance().shadow_config()
+}
+
+pub fn save_config(config: &SectionConfigData<SubscriptionKeyEntry>) -> Result<(), Error> {
+ instance().save_config(config)
+}
+
+pub fn save_shadow(shadow: &SectionConfigData<SubscriptionKeyShadow>) -> Result<(), Error> {
+ instance().save_shadow(shadow)
+}
+
+pub trait SubscriptionKeyConfig {
+ fn config(&self) -> Result<(SectionConfigData<SubscriptionKeyEntry>, ConfigDigest), Error>;
+ fn shadow_config(&self) -> Result<SectionConfigData<SubscriptionKeyShadow>, Error>;
+ fn lock_config(&self) -> Result<ApiLockGuard, Error>;
+ fn save_config(&self, config: &SectionConfigData<SubscriptionKeyEntry>) -> Result<(), Error>;
+ fn save_shadow(&self, shadow: &SectionConfigData<SubscriptionKeyShadow>) -> Result<(), Error>;
+}
+
+pub struct DefaultSubscriptionKeyConfig;
+
+impl SubscriptionKeyConfig for DefaultSubscriptionKeyConfig {
+ fn lock_config(&self) -> Result<ApiLockGuard, Error> {
+ open_api_lockfile(SUBSCRIPTIONS_CFG_LOCKFILE, None, true)
+ }
+
+ fn config(&self) -> Result<(SectionConfigData<SubscriptionKeyEntry>, 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<SectionConfigData<SubscriptionKeyShadow>, 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<SubscriptionKeyEntry>) -> 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<SubscriptionKeyShadow>) -> 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<dyn SubscriptionKeyConfig + Send + Sync>) {
+ 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
next prev parent reply other threads:[~2026-05-07 7:25 UTC|newest]
Thread overview: 13+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-05-07 7:17 [PATCH 0/8] subscription: add central key pool registry with reissue support Thomas Lamprecht
2026-05-07 7:17 ` [PATCH 1/8] api: subscription cache: ensure max_age=0 forces a fresh fetch Thomas Lamprecht
2026-05-07 7:17 ` [PATCH 2/8] api types: subscription level: render full names Thomas Lamprecht
2026-05-07 7:17 ` Thomas Lamprecht [this message]
2026-05-07 7:17 ` [PATCH 4/8] subscription: add key pool and node status API endpoints Thomas Lamprecht
2026-05-07 7:17 ` [PATCH 5/8] ui: add subscription registry with key pool and node status Thomas Lamprecht
2026-05-07 8:15 ` Lukas Wagner
2026-05-07 8:33 ` Thomas Lamprecht
2026-05-07 7:17 ` [PATCH 6/8] cli: add subscription key pool management subcommands Thomas Lamprecht
2026-05-07 7:17 ` [PATCH 7/8] docs: add subscription registry chapter Thomas Lamprecht
2026-05-07 7:17 ` [PATCH 8/8] subscription: add Reissue Key action with pending-reissue queue Thomas Lamprecht
2026-05-07 7:50 ` Lukas Wagner
2026-05-07 8:38 ` superseded: [PATCH 0/8] subscription: add central key pool registry with reissue support Thomas Lamprecht
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=20260507072436.2649563-4-t.lamprecht@proxmox.com \
--to=t.lamprecht@proxmox.com \
--cc=pdm-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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox