public inbox for pdm-devel@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 v3 06/12] cli: client: add subscription key pool management subcommands
Date: Fri, 15 May 2026 09:43:16 +0200	[thread overview]
Message-ID: <20260515074623.766766-7-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260515074623.766766-1-t.lamprecht@proxmox.com>

Plumb the new key-pool API endpoints through the CLI under the
existing `subscriptions` command group. The pre-existing `status`
subcommand becomes a sibling rather than the sole entry.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---

Changes v2 -> 3:
* Worker outcomes surface via the new wait_for_local_task helper from
  v3-0002 instead of a hand-rolled poll loop.
* CLI for the new v3 endpoints (Clear Key, Adopt Key / Adopt All,
  Check Subscription) is wired in their respective per-feature commits,
  not here.

 cli/client/src/subscriptions.rs | 260 +++++++++++++++++++++++++++++++-
 lib/pdm-client/src/lib.rs       | 179 +++++++++++++++++++++-
 2 files changed, 430 insertions(+), 9 deletions(-)

diff --git a/cli/client/src/subscriptions.rs b/cli/client/src/subscriptions.rs
index d8bf1e09..00c06ada 100644
--- a/cli/client/src/subscriptions.rs
+++ b/cli/client/src/subscriptions.rs
@@ -1,18 +1,46 @@
 use anyhow::Error;
 
+use proxmox_config_digest::PROXMOX_CONFIG_DIGEST_SCHEMA;
 use proxmox_router::cli::{
-    format_and_print_result, CliCommand, CommandLineInterface, OutputFormat,
+    format_and_print_result, CliCommand, CliCommandMap, CommandLineInterface, OutputFormat,
 };
 use proxmox_schema::api;
 
-use pdm_api_types::subscription::RemoteSubscriptionState;
-use pdm_api_types::VIEW_ID_SCHEMA;
+use pdm_api_types::remotes::REMOTE_ID_SCHEMA;
+use pdm_api_types::subscription::{RemoteSubscriptionState, SUBSCRIPTION_KEY_SCHEMA};
+use pdm_api_types::{NODE_SCHEMA, VIEW_ID_SCHEMA};
+use pdm_client::ConfigDigest;
 
 use crate::env::emoji;
 use crate::{client, env};
 
 pub fn cli() -> CommandLineInterface {
-    CliCommand::new(&API_METHOD_GET_SUBSCRIPTION_STATUS).into()
+    CliCommandMap::new()
+        .insert(
+            "status",
+            CliCommand::new(&API_METHOD_GET_SUBSCRIPTION_STATUS),
+        )
+        .insert("list-keys", CliCommand::new(&API_METHOD_LIST_KEYS))
+        .insert(
+            "add-keys",
+            CliCommand::new(&API_METHOD_ADD_KEYS).arg_param(&["keys"]),
+        )
+        .insert(
+            "assign-key",
+            CliCommand::new(&API_METHOD_ASSIGN_KEY).arg_param(&["key"]),
+        )
+        .insert(
+            "clear-assignment",
+            CliCommand::new(&API_METHOD_CLEAR_ASSIGNMENT).arg_param(&["key"]),
+        )
+        .insert(
+            "remove-key",
+            CliCommand::new(&API_METHOD_REMOVE_KEY).arg_param(&["key"]),
+        )
+        .insert("auto-assign", CliCommand::new(&API_METHOD_AUTO_ASSIGN))
+        .insert("apply-pending", CliCommand::new(&API_METHOD_APPLY_PENDING))
+        .insert("clear-pending", CliCommand::new(&API_METHOD_CLEAR_PENDING))
+        .into()
 }
 
 #[api(
@@ -37,7 +65,7 @@ pub fn cli() -> CommandLineInterface {
         },
     }
 )]
-/// List all the remotes this instance is managing.
+/// Show the subscription status of all remotes.
 async fn get_subscription_status(
     max_age: Option<u64>,
     verbose: Option<bool>,
@@ -106,3 +134,225 @@ async fn get_subscription_status(
     }
     Ok(())
 }
+
+#[api]
+/// List all subscription keys in the pool.
+async fn list_keys() -> Result<(), Error> {
+    let (keys, _digest) = client()?.list_subscription_keys().await?;
+
+    let output_format = env().format_args.output_format;
+    if output_format == OutputFormat::Text {
+        if keys.is_empty() {
+            println!("No keys in pool.");
+            return Ok(());
+        }
+        let key_width = keys.iter().map(|k| k.key.len()).max().unwrap_or(20);
+        for key in &keys {
+            let assignment = match (&key.remote, &key.node) {
+                (Some(r), Some(n)) => format!("{r}/{n}"),
+                _ => "(unassigned)".to_string(),
+            };
+            println!(
+                "  {key:<kw$}  {product:<5}  {level:<10}  {status:<10}  {assignment}",
+                key = key.key,
+                kw = key_width,
+                product = key.product_type.to_string(),
+                level = key.level.to_string(),
+                status = key.status.to_string(),
+            );
+        }
+    } else {
+        format_and_print_result(&keys, &output_format.to_string());
+    }
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            keys: {
+                type: Array,
+                description: "Subscription keys to add to the pool.",
+                items: { schema: SUBSCRIPTION_KEY_SCHEMA },
+            },
+            digest: {
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+                optional: true,
+            },
+        },
+    },
+)]
+/// Add one or more subscription keys to the pool.
+async fn add_keys(keys: Vec<String>, digest: Option<String>) -> Result<(), Error> {
+    let digest = digest.map(ConfigDigest::from);
+    client()?.add_subscription_keys(&keys, digest).await?;
+    let n = keys.len();
+    if n == 1 {
+        println!("Added {} to pool.", keys[0]);
+    } else {
+        println!("Added {n} keys to pool.");
+    }
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            key: { schema: SUBSCRIPTION_KEY_SCHEMA },
+            remote: { schema: REMOTE_ID_SCHEMA },
+            node: { schema: NODE_SCHEMA },
+            digest: {
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+                optional: true,
+            },
+        },
+    },
+)]
+/// Assign a key from the pool to a remote node.
+async fn assign_key(
+    key: String,
+    remote: String,
+    node: String,
+    digest: Option<String>,
+) -> Result<(), Error> {
+    let digest = digest.map(ConfigDigest::from);
+    client()?
+        .set_subscription_assignment(&key, &remote, &node, digest)
+        .await?;
+    println!("Assigned {key} to {remote}/{node}.");
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            key: { schema: SUBSCRIPTION_KEY_SCHEMA },
+            digest: {
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+                optional: true,
+            },
+        },
+    },
+)]
+/// Clear the assignment of a key (unassign from its remote node).
+async fn clear_assignment(key: String, digest: Option<String>) -> Result<(), Error> {
+    let digest = digest.map(ConfigDigest::from);
+    client()?
+        .clear_subscription_assignment(&key, digest)
+        .await?;
+    println!("Cleared assignment for {key}.");
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            key: { schema: SUBSCRIPTION_KEY_SCHEMA },
+        },
+    },
+)]
+/// Remove a key from the pool entirely.
+async fn remove_key(key: String) -> Result<(), Error> {
+    client()?.delete_subscription_key(&key).await?;
+    println!("Removed {key} from pool.");
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            apply: {
+                type: bool,
+                optional: true,
+                default: false,
+                description: "Commit the proposal immediately via bulk-assign. \
+                              Without this, only a preview is printed.",
+            },
+        },
+    },
+)]
+/// Propose (and optionally apply) automatic key-to-node assignments.
+async fn auto_assign(apply: bool) -> Result<(), Error> {
+    let client = client()?;
+    let proposal = client.subscription_auto_assign().await?;
+
+    if proposal.assignments.is_empty() {
+        println!("No suitable free keys for nodes without an active subscription.");
+        return Ok(());
+    }
+
+    let verb = if apply { "assigned" } else { "proposed" };
+    for p in &proposal.assignments {
+        println!("  {verb}: {} -> {}/{}", p.key, p.remote, p.node);
+    }
+
+    if !apply {
+        println!("\nRe-run with --apply to apply these assignments.");
+        return Ok(());
+    }
+    let applied = client.subscription_bulk_assign(proposal).await?;
+    if applied.is_empty() {
+        println!("\nServer rejected the proposal (no entries applied).");
+    }
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            digest: {
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+                optional: true,
+            },
+        },
+    },
+)]
+/// Push all pending key assignments to remotes as a worker task.
+///
+/// Blocks until the worker finishes so the operator sees the exit status of the actual push
+/// run, not just a UPID they would have to chase down by hand.
+async fn apply_pending(digest: Option<String>) -> Result<(), Error> {
+    let digest = digest.map(ConfigDigest::from);
+    let client = client()?;
+    let upid = match client.subscription_apply_pending(digest).await? {
+        None => {
+            println!("No pending assignments to apply.");
+            return Ok(());
+        }
+        Some(upid) => upid,
+    };
+    println!("Started worker task: {upid}");
+    let status = client.wait_for_local_task(&upid).await?;
+    let exit = status
+        .get("exitstatus")
+        .and_then(|v| v.as_str())
+        .unwrap_or("unknown");
+    if exit == "OK" {
+        println!("Task finished: OK");
+        Ok(())
+    } else {
+        anyhow::bail!("worker task ended with: {exit}");
+    }
+}
+
+#[api(
+    input: {
+        properties: {
+            digest: {
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+                optional: true,
+            },
+        },
+    },
+)]
+/// Clear every pending assignment in one bulk transaction.
+async fn clear_pending(digest: Option<String>) -> Result<(), Error> {
+    let digest = digest.map(ConfigDigest::from);
+    let cleared = client()?.subscription_clear_pending(digest).await?;
+    if cleared == 0 {
+        println!("No pending assignments to clear.");
+    } else {
+        println!("Cleared {cleared} pending assignment(s).");
+    }
+    Ok(())
+}
diff --git a/lib/pdm-client/src/lib.rs b/lib/pdm-client/src/lib.rs
index cb5bb043..1fed0e85 100644
--- a/lib/pdm-client/src/lib.rs
+++ b/lib/pdm-client/src/lib.rs
@@ -76,7 +76,10 @@ pub mod types {
 
     pub use pve_api_types::StorageStatus as PveStorageStatus;
 
-    pub use pdm_api_types::subscription::{RemoteSubscriptionState, RemoteSubscriptions};
+    pub use pdm_api_types::subscription::{
+        AutoAssignProposal, ClearPendingResult, ProductType, ProposedAssignment, RemoteNodeStatus,
+        RemoteSubscriptionState, RemoteSubscriptions, SubscriptionKeyEntry, SubscriptionKeySource,
+    };
 
     pub use pve_api_types::{SdnVnetMacVrf, SdnZoneIpVrf};
 }
@@ -898,9 +901,6 @@ impl<T: HttpApiClient> PdmClient<T> {
     /// server-side wait surface lands this method becomes a single GET with no behaviour change
     /// for callers.
     ///
-    /// No built-in time bound; wrap in `tokio::time::timeout` if needed. Dropping the future
-    /// stops the client-side polling only - the server-side worker keeps running.
-    ///
     /// Native-only: the polling loop relies on `tokio::time::sleep`, which is not available on
     /// the wasm32 target the UI builds for.
     #[cfg(not(target_arch = "wasm32"))]
@@ -1119,6 +1119,177 @@ impl<T: HttpApiClient> PdmClient<T> {
         Ok(self.0.get(&path).await?.expect_json()?.data)
     }
 
+    /// List all keys in the subscription pool. Returns the entries plus the matching
+    /// `ConfigDigest` so the caller can chain a digest-aware add / assign / delete back.
+    pub async fn list_subscription_keys(
+        &self,
+    ) -> Result<(Vec<SubscriptionKeyEntry>, Option<ConfigDigest>), Error> {
+        let mut res = self
+            .0
+            .get("/api2/extjs/subscriptions/keys")
+            .await?
+            .expect_json()?;
+        Ok((res.data, res.attribs.remove("digest").map(ConfigDigest)))
+    }
+
+    /// Add one or more keys to the pool. See the daemon-side endpoint for the all-or-nothing
+    /// validation semantics.
+    pub async fn add_subscription_keys(
+        &self,
+        keys: &[String],
+        digest: Option<ConfigDigest>,
+    ) -> Result<(), Error> {
+        #[derive(Serialize)]
+        struct AddArgs<'a> {
+            keys: &'a [String],
+            #[serde(skip_serializing_if = "Option::is_none")]
+            digest: Option<ConfigDigest>,
+        }
+        self.0
+            .post("/api2/extjs/subscriptions/keys", &AddArgs { keys, digest })
+            .await?
+            .nodata()
+    }
+
+    /// Bind a key to a remote node.
+    pub async fn set_subscription_assignment(
+        &self,
+        key: &str,
+        remote: &str,
+        node: &str,
+        digest: Option<ConfigDigest>,
+    ) -> Result<(), Error> {
+        #[derive(Serialize)]
+        struct AssignArgs<'a> {
+            remote: &'a str,
+            node: &'a str,
+            #[serde(skip_serializing_if = "Option::is_none")]
+            digest: Option<ConfigDigest>,
+        }
+        let path = format!("/api2/extjs/subscriptions/keys/{key}/assignment");
+        self.0
+            .post(
+                &path,
+                &AssignArgs {
+                    remote,
+                    node,
+                    digest,
+                },
+            )
+            .await?
+            .nodata()
+    }
+
+    /// Drop the remote-node binding for a pool key (the inverse of
+    /// [`set_subscription_assignment`]).
+    pub async fn clear_subscription_assignment(
+        &self,
+        key: &str,
+        digest: Option<ConfigDigest>,
+    ) -> Result<(), Error> {
+        let path = ApiPathBuilder::new(format!(
+            "/api2/extjs/subscriptions/keys/{key}/assignment"
+        ))
+        .maybe_arg("digest", &digest.map(Value::from))
+        .build();
+        self.0.delete(&path).await?.nodata()
+    }
+
+    /// Remove a key from the pool entirely.
+    ///
+    /// No digest parameter: deletion is a point-of-no-return operation and the typed-client
+    /// surface elsewhere (delete_remote, delete_user, ...) does not round-trip a digest on
+    /// DELETE either. External REST callers can still pass `digest` via the URL query if they
+    /// want optimistic concurrency on deletion; the server-side endpoint accepts it.
+    pub async fn delete_subscription_key(&self, key: &str) -> Result<(), Error> {
+        let path = format!("/api2/extjs/subscriptions/keys/{key}");
+        self.0.delete(&path).await?.nodata()
+    }
+
+    /// Combined remote/node subscription status, filtered to remotes the caller has audit
+    /// privilege on.
+    pub async fn subscription_node_status(
+        &self,
+        max_age: Option<u64>,
+    ) -> Result<Vec<RemoteNodeStatus>, Error> {
+        let path = ApiPathBuilder::new("/api2/extjs/subscriptions/node-status")
+            .maybe_arg("max-age", &max_age)
+            .build();
+        Ok(self.0.get(&path).await?.expect_json()?.data)
+    }
+
+    /// Compute a key-to-node assignment proposal. Apply it with
+    /// [`subscription_bulk_assign`].
+    pub async fn subscription_auto_assign(&self) -> Result<AutoAssignProposal, Error> {
+        Ok(self
+            .0
+            .post("/api2/extjs/subscriptions/auto-assign", &json!({}))
+            .await?
+            .expect_json()?
+            .data)
+    }
+
+    /// Commit a proposal previously returned by [`subscription_auto_assign`]. The server
+    /// rejects the call with 409 if either the pool or the live node-status has drifted
+    /// since the proposal was computed.
+    pub async fn subscription_bulk_assign(
+        &self,
+        proposal: AutoAssignProposal,
+    ) -> Result<Vec<ProposedAssignment>, Error> {
+        Ok(self
+            .0
+            .post(
+                "/api2/extjs/subscriptions/bulk-assign",
+                &json!({ "proposal": proposal }),
+            )
+            .await?
+            .expect_json()?
+            .data)
+    }
+
+    /// Push every pending assignment. Returns the worker UPID, or `None` when there is nothing
+    /// to do.
+    ///
+    /// The optional `digest` rejects the call at the API boundary if the pool changed since the
+    /// caller last loaded it - the at-API-call-time plan is pinned, but the worker re-reads when
+    /// it fires, so a parallel admin edit between API return and worker start is still honoured.
+    pub async fn subscription_apply_pending(
+        &self,
+        digest: Option<ConfigDigest>,
+    ) -> Result<Option<String>, Error> {
+        #[derive(Serialize)]
+        struct Args {
+            #[serde(skip_serializing_if = "Option::is_none")]
+            digest: Option<ConfigDigest>,
+        }
+        Ok(self
+            .0
+            .post("/api2/extjs/subscriptions/apply-pending", &Args { digest })
+            .await?
+            .expect_json()?
+            .data)
+    }
+
+    /// Clear every pending assignment in one bulk transaction; returns the count of cleared
+    /// entries.
+    pub async fn subscription_clear_pending(
+        &self,
+        digest: Option<ConfigDigest>,
+    ) -> Result<u32, Error> {
+        #[derive(Serialize)]
+        struct Args {
+            #[serde(skip_serializing_if = "Option::is_none")]
+            digest: Option<ConfigDigest>,
+        }
+        let result: types::ClearPendingResult = self
+            .0
+            .post("/api2/extjs/subscriptions/clear-pending", &Args { digest })
+            .await?
+            .expect_json()?
+            .data;
+        Ok(result.cleared)
+    }
+
     pub async fn pve_list_networks(
         &self,
         remote: &str,
-- 
2.47.3





  parent reply	other threads:[~2026-05-15  7:46 UTC|newest]

Thread overview: 16+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-15  7:43 [PATCH datacenter-manager v3 00/12] subscription key pool registry Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 01/12] api types: subscription level: render full names Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 02/12] pdm-client: add wait_for_local_task helper Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` [PATCH datacenter-manager v3 03/12] subscription: pool: add data model and config layer Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` [PATCH datacenter-manager v3 04/12] subscription: api: add key pool and node status endpoints Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` [PATCH datacenter-manager v3 05/12] ui: registry: add view with key pool and node status Thomas Lamprecht
2026-05-15  7:43 ` Thomas Lamprecht [this message]
2026-05-15  7:43 ` [PATCH datacenter-manager v3 07/12] docs: add subscription registry chapter Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 08/12] subscription: add Clear Key action and per-node revert Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 09/12] subscription: add Adopt Key action for foreign live subscriptions Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 10/12] subscription: add Adopt All bulk action Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 11/12] subscription: add Check Subscription action Thomas Lamprecht
2026-05-15  7:43 ` [RFC PATCH datacenter-manager v3 12/12] ui: registry: add Add-and-Assign wizard from Assign Key dialog 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=20260515074623.766766-7-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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal