From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v2 6/8] cli: add subscription key pool management subcommands
Date: Thu, 7 May 2026 10:26:47 +0200 [thread overview]
Message-ID: <20260507082943.2749725-7-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260507082943.2749725-1-t.lamprecht@proxmox.com>
Expose the new key-pool API to the CLI so that the subscriptions
command group gains the following sub commands:
list-keys
add-keys (variadic)
assign-key
clear-key
remove-key
auto-assign
apply-pending
clear-pending
The pre-existing `status` subcommand becomes a sibling under this
`subscriptions` group.
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---
cli/client/src/subscriptions.rs | 195 +++++++++++++++++++++++++++++++-
lib/pdm-client/src/lib.rs | 126 ++++++++++++++++++++-
2 files changed, 316 insertions(+), 5 deletions(-)
diff --git a/cli/client/src/subscriptions.rs b/cli/client/src/subscriptions.rs
index d8bf1e0..5d5532b 100644
--- a/cli/client/src/subscriptions.rs
+++ b/cli/client/src/subscriptions.rs
@@ -1,18 +1,44 @@
use anyhow::Error;
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::remotes::REMOTE_ID_SCHEMA;
+use pdm_api_types::subscription::{RemoteSubscriptionState, SUBSCRIPTION_KEY_SCHEMA};
use pdm_api_types::VIEW_ID_SCHEMA;
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-key",
+ CliCommand::new(&API_METHOD_CLEAR_KEY).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 +63,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 +132,164 @@ 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 },
+ },
+ },
+ },
+)]
+/// Add one or more subscription keys to the pool.
+async fn add_keys(keys: Vec<String>) -> Result<(), Error> {
+ client()?.add_subscription_keys(&keys, None).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: {
+ type: String,
+ description: "Node name within the remote.",
+ },
+ },
+ },
+)]
+/// Assign a key from the pool to a remote node.
+async fn assign_key(key: String, remote: String, node: String) -> Result<(), Error> {
+ client()?
+ .assign_subscription_key(&key, Some(&remote), Some(&node), None)
+ .await?;
+ println!("Assigned {key} to {remote}/{node}.");
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ key: { schema: SUBSCRIPTION_KEY_SCHEMA },
+ },
+ },
+)]
+/// Clear the assignment of a key (unassign from its remote node).
+async fn clear_key(key: String) -> Result<(), Error> {
+ client()?
+ .assign_subscription_key(&key, None, None, None)
+ .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: "Apply the proposed assignments immediately.",
+ },
+ },
+ },
+)]
+/// Propose or apply automatic key-to-node assignments.
+async fn auto_assign(apply: Option<bool>) -> Result<(), Error> {
+ let apply = apply.unwrap_or(false);
+ let proposals = client()?.subscription_auto_assign(apply).await?;
+
+ if proposals.is_empty() {
+ println!("No suitable keys available for unsubscribed nodes.");
+ return Ok(());
+ }
+
+ for p in &proposals {
+ let verb = if apply { "assigned" } else { "proposed" };
+ println!(" {verb}: {} -> {}/{}", p.key, p.remote, p.node);
+ }
+
+ if !apply {
+ println!("\nRe-run with --apply to apply these assignments.");
+ }
+ Ok(())
+}
+
+#[api]
+/// Push all pending key assignments to remotes as a worker task.
+async fn apply_pending() -> Result<(), Error> {
+ match client()?.subscription_apply_pending().await? {
+ None => println!("No pending assignments to apply."),
+ Some(upid) => println!("Task started: {upid}"),
+ }
+ Ok(())
+}
+
+#[api]
+/// Clear every pending assignment in one bulk transaction.
+async fn clear_pending() -> Result<(), Error> {
+ let cleared = client()?.subscription_clear_pending().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 76b33ef..b0527b1 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::{
+ ClearPendingResult, ProductType, ProposedAssignment, RemoteNodeStatus,
+ RemoteSubscriptionState, RemoteSubscriptions, SubscriptionKeyEntry, SubscriptionKeySource,
+ };
pub use pve_api_types::{SdnVnetMacVrf, SdnZoneIpVrf};
}
@@ -1089,6 +1092,127 @@ 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()
+ }
+
+ /// Assign a key to a remote node. Pass `None` for both `remote` and `node` to clear the
+ /// assignment instead.
+ pub async fn assign_subscription_key(
+ &self,
+ key: &str,
+ remote: Option<&str>,
+ node: Option<&str>,
+ digest: Option<ConfigDigest>,
+ ) -> Result<(), Error> {
+ #[derive(Serialize)]
+ struct AssignArgs<'a> {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ remote: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ node: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ digest: Option<ConfigDigest>,
+ }
+ let path = format!("/api2/extjs/subscriptions/keys/{key}");
+ self.0
+ .put(
+ &path,
+ &AssignArgs {
+ remote,
+ node,
+ digest,
+ },
+ )
+ .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)
+ }
+
+ /// Propose (or apply) automatic key-to-node assignments.
+ pub async fn subscription_auto_assign(
+ &self,
+ apply: bool,
+ ) -> Result<Vec<ProposedAssignment>, Error> {
+ let path = ApiPathBuilder::new("/api2/extjs/subscriptions/auto-assign")
+ .arg("apply", &apply)
+ .build();
+ Ok(self.0.post(&path, &json!({})).await?.expect_json()?.data)
+ }
+
+ /// Push every pending assignment. Returns the worker UPID, or `None` when there is nothing
+ /// to do.
+ pub async fn subscription_apply_pending(&self) -> Result<Option<String>, Error> {
+ Ok(self
+ .0
+ .post("/api2/extjs/subscriptions/apply-pending", &json!({}))
+ .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) -> Result<u32, Error> {
+ let result: types::ClearPendingResult = self
+ .0
+ .post("/api2/extjs/subscriptions/clear-pending", &json!({}))
+ .await?
+ .expect_json()?
+ .data;
+ Ok(result.cleared)
+ }
+
pub async fn pve_list_networks(
&self,
remote: &str,
--
2.47.3
next prev 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 ` [PATCH datacenter-manager v2 3/8] subscription: add key pool data model and config layer Thomas Lamprecht
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 ` Thomas Lamprecht [this message]
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-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