public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Lukas Wagner" <l.wagner@proxmox.com>
To: "Thomas Lamprecht" <t.lamprecht@proxmox.com>,
	<pdm-devel@lists.proxmox.com>
Subject: Re: [PATCH datacenter-manager v2 3/8] subscription: add key pool data model and config layer
Date: Tue, 12 May 2026 11:51:06 +0200	[thread overview]
Message-ID: <DIGLSV3H83B7.3KL80LI6TMI7Q@proxmox.com> (raw)
In-Reply-To: <20260507082943.2749725-4-t.lamprecht@proxmox.com>

Looking good mostly, some notes inline.

Inline comments are mostly cosmetic, so in any way:

Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>

On Thu May 7, 2026 at 10:26 AM CEST, Thomas Lamprecht wrote:
[...]
> +#[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,

the subscription level is serialized uppercase, e.g. "Community" -- is
this intentional? Not a big deal IMO, just wanted to make sure if this was
on purpose or not.

> +
> +    /// 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>,

Same note as at (1) with regards to hyphenation, but can also stay as is
to have consistency with the API in PVE and PBS...

> +
> +    /// Subscription status from last check.
> +    #[serde(default)]
> +    pub status: SubscriptionStatus,
> +
> +    /// Next due date.
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    pub nextduedate: Option<String>,

(1)

Personally i'm not a big fan of these word amalgamations, I think I'd
prefer 'next_due_date' (serialized as 'next-due-date'). That being said,
it might still be fine to keep as is, since that is the exact spelling
used in the PVE/PBS API ... so no hard feelings from my side.

> +
> +    /// Product name.
> +    #[serde(skip_serializing_if = "Option::is_none")]
> +    pub productname: Option<String>,

Same as (1). Additionally, we have `product-type` above, so I think
`product-name` would make the config make self-consistent.

> +
> +    /// 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,
> +}

This could use some docstrings for the struct members, as to make it
clearer what `expected` and `actual` acutally refer to. Alternatively or
additionally, it could be {actual,expected}_server_id

> +
> +#[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-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

Doc-Strings for `pub` fn's in this file are missing. No big deal here
since the usage is clear enough from the signature alone, but still
mentioning it for completeness' sake.

> 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");
> +    }
> +}





  reply	other threads:[~2026-05-12  9:51 UTC|newest]

Thread overview: 21+ 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 ` [PATCH datacenter-manager v2 3/8] subscription: add key pool data model and config layer Thomas Lamprecht
2026-05-12  9:51   ` Lukas Wagner [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-12 12:21   ` 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-12 14:45   ` Lukas Wagner
2026-05-07  8:26 ` [PATCH datacenter-manager v2 6/8] cli: add subscription key pool management subcommands Thomas Lamprecht
2026-05-12 12:56   ` Lukas Wagner
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-12 13:57   ` Lukas Wagner
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
2026-05-15  7:48 ` superseded: " 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=DIGLSV3H83B7.3KL80LI6TMI7Q@proxmox.com \
    --to=l.wagner@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    --cc=t.lamprecht@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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal