From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 5F1741FF13F for ; Thu, 07 May 2026 09:25:27 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 404D8C745; Thu, 7 May 2026 09:25:27 +0200 (CEST) From: Thomas Lamprecht To: pdm-devel@lists.proxmox.com Subject: [PATCH 6/8] cli: add subscription key pool management subcommands Date: Thu, 7 May 2026 09:17:29 +0200 Message-ID: <20260507072436.2649563-7-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260507072436.2649563-1-t.lamprecht@proxmox.com> References: <20260507072436.2649563-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1778138578396 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.003 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [lib.rs,subscriptions.rs] Message-ID-Hash: 4YUQXE7BYYXQKNIUWYNBXVNCD6BIV2VO X-Message-ID-Hash: 4YUQXE7BYYXQKNIUWYNBXVNCD6BIV2VO X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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, verbose: Option, @@ -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:) -> 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) -> 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 PdmClient { 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, Option), 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, + ) -> Result<(), Error> { + #[derive(Serialize)] + struct AddArgs<'a> { + keys: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + digest: Option, + } + 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, + ) -> 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, + } + 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, + ) -> Result, 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, 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, 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 { + 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