all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v2 3/8] subscription: add key pool data model and config layer
Date: Thu,  7 May 2026 10:26:44 +0200	[thread overview]
Message-ID: <20260507082943.2749725-4-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260507082943.2749725-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





  parent reply	other threads:[~2026-05-07  8:30 UTC|newest]

Thread overview: 15+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-07  8:26 [PATCH datacenter-manager v2 0/8] subscription: add central key pool registry with reissue support Thomas Lamprecht
2026-05-07  8:26 ` [PATCH datacenter-manager v2 1/8] api: subscription cache: ensure max_age=0 forces a fresh fetch Thomas Lamprecht
2026-05-07 13:23   ` Lukas Wagner
2026-05-08 12:43   ` applied: " Lukas Wagner
2026-05-07  8:26 ` [PATCH datacenter-manager v2 2/8] api types: subscription level: render full names Thomas Lamprecht
2026-05-07 13:23   ` Lukas Wagner
2026-05-07  8:26 ` Thomas Lamprecht [this message]
2026-05-07  8:26 ` [PATCH datacenter-manager v2 4/8] subscription: add key pool and node status API endpoints Thomas Lamprecht
2026-05-07 13:23   ` Lukas Wagner
2026-05-07  8:26 ` [PATCH datacenter-manager v2 5/8] ui: add subscription registry with key pool and node status Thomas Lamprecht
2026-05-07  8:26 ` [PATCH datacenter-manager v2 6/8] cli: add subscription key pool management subcommands Thomas Lamprecht
2026-05-07  8:26 ` [PATCH datacenter-manager v2 7/8] docs: add subscription registry chapter Thomas Lamprecht
2026-05-07  8:26 ` [PATCH datacenter-manager v2 8/8] subscription: add Reissue Key action with pending-reissue queue Thomas Lamprecht
2026-05-07  8:34 ` [PATCH datacenter-manager v2 9/9] fixup! ui: add subscription registry with key pool and node status Thomas Lamprecht
2026-05-07 13:23 ` [PATCH datacenter-manager v2 0/8] subscription: add central key pool registry with reissue support Lukas Wagner

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=20260507082943.2749725-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal