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 7D72B1FF14C for ; Fri, 15 May 2026 09:47:26 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 50FA7E8EB; Fri, 15 May 2026 09:47:26 +0200 (CEST) From: Thomas Lamprecht To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v3 11/12] subscription: add Check Subscription action Date: Fri, 15 May 2026 09:43:21 +0200 Message-ID: <20260515074623.766766-12-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260515074623.766766-1-t.lamprecht@proxmox.com> References: <20260515074623.766766-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: 1778831192986 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 Message-ID-Hash: CCO3EYWCCXXFTFNOBUXLC6WE7YUMTMVM X-Message-ID-Hash: CCO3EYWCCXXFTFNOBUXLC6WE7YUMTMVM 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: Wire a per-node Check Subscription action that drives the remote's `update_subscription(force=true)` endpoint (POST on PVE / PBS) and invalidates the PDM-side subscription cache so the next status read reflects the fresh shop verdict instead of a 5-minute-stale snapshot. Mirrors the per-product Check button on PVE and PBS, just driven from the central registry view. Useful when a node's live status has drifted to Invalid / Expired because of a shop-side change and the operator wants to promote the live verdict back to Active without waiting for the periodic check. PVE and PBS use the canonical `UpdateSubscription` typed binding (PVE via pve-api-types, PBS via proxmox-subscription). NodeSubscriptionInfo and RemoteNodeStatus grow optional check_time and next_due_date fields populated from the live SubscriptionInfo; the Status column tooltip surfaces both, where the remote reports them, so the operator can tell at a glance how fresh the last check is and when the subscription will next come due. Signed-off-by: Thomas Lamprecht --- New in v3. cli/client/src/subscriptions.rs | 23 ++++ docs/subscription-registry.rst | 7 + lib/pdm-api-types/src/subscription.rs | 14 ++ lib/pdm-client/src/lib.rs | 19 +++ server/src/api/resources.rs | 4 + server/src/api/subscriptions/mod.rs | 124 +++++++++++++++++- ui/src/configuration/subscription_registry.rs | 78 ++++++++++- 7 files changed, 259 insertions(+), 10 deletions(-) diff --git a/cli/client/src/subscriptions.rs b/cli/client/src/subscriptions.rs index 469f0841..e98e34fb 100644 --- a/cli/client/src/subscriptions.rs +++ b/cli/client/src/subscriptions.rs @@ -53,6 +53,10 @@ pub fn cli() -> CommandLineInterface { CliCommand::new(&API_METHOD_ADOPT_KEY).arg_param(&["remote", "node"]), ) .insert("adopt-all", CliCommand::new(&API_METHOD_ADOPT_ALL)) + .insert( + "check", + CliCommand::new(&API_METHOD_CHECK_SUBSCRIPTION).arg_param(&["remote", "node"]), + ) .into() } @@ -372,6 +376,25 @@ async fn adopt_all(digest: Option) -> Result<(), Error> { Ok(()) } +#[api( + input: { + properties: { + remote: { schema: REMOTE_ID_SCHEMA }, + node: { schema: NODE_SCHEMA }, + }, + }, +)] +/// Trigger a fresh shop-side subscription check on a remote node. +/// +/// Equivalent to the per-product "Check" button: re-verifies the live subscription status +/// against the shop. Useful for promoting a stale Invalid/Expired verdict to Active once the +/// underlying issue is fixed at the shop, without waiting for the next periodic check. +async fn check_subscription(remote: String, node: String) -> Result<(), Error> { + client()?.subscription_check(&remote, &node).await?; + println!("Re-checked subscription on {remote}/{node}."); + Ok(()) +} + #[api( input: { properties: { diff --git a/docs/subscription-registry.rst b/docs/subscription-registry.rst index 6d599fe2..3d64c0bc 100644 --- a/docs/subscription-registry.rst +++ b/docs/subscription-registry.rst @@ -63,6 +63,13 @@ The proposed plan can be inspected before it is applied. Apply Pending walks the order; if any push or clear fails the remaining queue is kept intact for retry. Discard Pending drops the plan without touching any remote. +The Check Subscription action triggers a fresh shop-side verification of the live subscription +on the selected node, equivalent to the per-product "Check" button on PVE / PBS. Useful for +promoting a stale ``Invalid`` or ``Expired`` verdict to ``Active`` once the underlying issue is +fixed at the shop, without having to wait for the next periodic check. The Status column tooltip +surfaces the last-checked timestamp and the next-due-date as reported by the remote, where +available. + Permissions ----------- diff --git a/lib/pdm-api-types/src/subscription.rs b/lib/pdm-api-types/src/subscription.rs index df1fec1c..32706654 100644 --- a/lib/pdm-api-types/src/subscription.rs +++ b/lib/pdm-api-types/src/subscription.rs @@ -120,6 +120,14 @@ pub struct NodeSubscriptionInfo { /// Serverid of the node, if accessible #[serde(skip_serializing)] pub serverid: Option, + + /// Epoch of the last successful subscription check on the node. + #[serde(skip_serializing_if = "Option::is_none")] + pub check_time: Option, + + /// Next due date of the subscription, as reported by the remote. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_due_date: Option, } #[api( @@ -558,6 +566,12 @@ pub struct RemoteNodeStatus { /// True when the pool has a clear queued for this node. Omitted on the wire when false. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub pending_clear: bool, + /// Epoch of the last successful subscription check on the node. + #[serde(skip_serializing_if = "Option::is_none")] + pub check_time: Option, + /// Next due date of the subscription, as reported by the remote. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_due_date: Option, } #[api] diff --git a/lib/pdm-client/src/lib.rs b/lib/pdm-client/src/lib.rs index f03f6c40..eb7a7e89 100644 --- a/lib/pdm-client/src/lib.rs +++ b/lib/pdm-client/src/lib.rs @@ -1385,6 +1385,25 @@ impl PdmClient { .nodata() } + /// Trigger a fresh shop-side subscription check on `remote`/`node`. Equivalent to the + /// per-product "Check" button: drives `update_subscription(force=true)` and invalidates the + /// remote's cached subscription state so the next `subscription_node_status` reflects the + /// new verdict. + pub async fn subscription_check(&self, remote: &str, node: &str) -> Result<(), Error> { + #[derive(Serialize)] + struct Args<'a> { + remote: &'a str, + node: &'a str, + } + self.0 + .post( + "/api2/extjs/subscriptions/check", + &Args { remote, node }, + ) + .await? + .nodata() + } + /// Clear every pending assignment in one bulk transaction; returns the count of cleared /// entries. pub async fn subscription_clear_pending( diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs index d4ed5ab0..8825010c 100644 --- a/server/src/api/resources.rs +++ b/server/src/api/resources.rs @@ -959,6 +959,8 @@ async fn fetch_remote_subscription_info( .level .and_then(|level| level.parse().ok()) .unwrap_or_default(), + check_time: info.checktime, + next_due_date: info.nextduedate, } }), ); @@ -975,6 +977,8 @@ async fn fetch_remote_subscription_info( key: info.key, level, serverid: info.serverid, + check_time: info.checktime, + next_due_date: info.nextduedate, } }); diff --git a/server/src/api/subscriptions/mod.rs b/server/src/api/subscriptions/mod.rs index a8f5cfc5..6b5b4cc0 100644 --- a/server/src/api/subscriptions/mod.rs +++ b/server/src/api/subscriptions/mod.rs @@ -47,6 +47,7 @@ const SUBDIRS: SubdirMap = &sorted!([ ), ("auto-assign", &Router::new().post(&API_METHOD_AUTO_ASSIGN)), ("bulk-assign", &Router::new().post(&API_METHOD_BULK_ASSIGN)), + ("check", &Router::new().post(&API_METHOD_CHECK_SUBSCRIPTION)), ( "clear-pending", &Router::new().post(&API_METHOD_CLEAR_PENDING) @@ -799,6 +800,38 @@ async fn delete_subscription_on_remote( Ok(()) } +/// Trigger a fresh shop-side subscription check on `remote`/`node` and return once the remote +/// has stored the result. Equivalent to the per-product "Check" button, just driven through PDM. +async fn check_subscription_on_remote( + remote: &Remote, + product_type: ProductType, + node_name: &str, +) -> Result<(), Error> { + match product_type { + ProductType::Pve => { + let client = crate::connection::make_pve_client(remote)?; + client + .update_subscription( + node_name, + pve_api_types::UpdateSubscription { force: Some(true) }, + ) + .await?; + } + ProductType::Pbs => { + let client = crate::connection::make_pbs_client(remote)?; + client + .check_subscription(proxmox_subscription::UpdateSubscription { force: Some(true) }) + .await?; + } + ProductType::Pmg | ProductType::Pom => { + bail!("PDM cannot check '{product_type}' keys: no remote support yet"); + } + } + + info!("re-checked subscription on {}/{node_name}", remote.id); + Ok(()) +} + #[api( input: { properties: { @@ -961,6 +994,63 @@ async fn revert_pending_clear( Ok(()) } +#[api( + input: { + properties: { + remote: { schema: REMOTE_ID_SCHEMA }, + // NODE_SCHEMA rejects path-traversal input before it ends up interpolated into the + // remote URL `/api2/extjs/nodes/{node}/subscription`. + node: { schema: NODE_SCHEMA }, + }, + }, + access: { + permission: &Permission::Privilege(&["system"], PRIV_SYS_MODIFY, false), + }, +)] +/// Trigger a fresh shop-side subscription check on `remote`/`node`. +/// +/// Mirrors the per-product "Check" button on PVE / PBS: drives the remote's +/// `update_subscription(force=true)` endpoint so a status that went stale at the shop (Invalid, +/// Expired) gets re-verified without waiting for the next periodic check. The cached +/// subscription state for the remote is invalidated so the next node-status read reflects the +/// fresh verdict instead of a 5-minute-stale snapshot. +/// +/// Per-remote `PRIV_RESOURCE_MODIFY` is enforced inside the handler since the call costs an +/// outbound HTTPS request to the shop. +async fn check_subscription( + remote: String, + node: String, + rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let auth_id: Authid = rpcenv + .get_auth_id() + .context("no authid available")? + .parse()?; + let user_info = CachedUserInfo::new()?; + user_info.check_privs( + &auth_id, + &["resource", &remote], + PRIV_RESOURCE_MODIFY, + false, + )?; + + let (remotes_config, _) = pdm_config::remotes::config()?; + let remote_entry = remotes_config + .get(&remote) + .ok_or_else(|| http_err!(NOT_FOUND, "remote '{remote}' not found"))?; + + let product_type = match remote_entry.ty { + pdm_api_types::remotes::RemoteType::Pve => ProductType::Pve, + pdm_api_types::remotes::RemoteType::Pbs => ProductType::Pbs, + }; + + check_subscription_on_remote(remote_entry, product_type, &node) + .await + .map_err(|err| http_err!(BAD_REQUEST, "check failed on {remote}/{node}: {err}"))?; + invalidate_subscription_info_for_remote(&remote); + Ok(()) +} + #[api( input: { properties: { @@ -1340,13 +1430,23 @@ async fn collect_node_status( }; for (node_name, node_info) in &node_infos { - let (status, level, sockets, current_key) = match node_info { - Some(info) => (info.status, info.level, info.sockets, info.key.clone()), + let (status, level, sockets, current_key, check_time, next_due_date) = match node_info + { + Some(info) => ( + info.status, + info.level, + info.sockets, + info.key.clone(), + info.check_time, + info.next_due_date.clone(), + ), None => ( proxmox_subscription::SubscriptionStatus::NotFound, SubscriptionLevel::None, None, None, + None, + None, ), }; @@ -1369,6 +1469,8 @@ async fn collect_node_status( assigned_key, current_key, pending_clear, + check_time, + next_due_date, }); } } @@ -1996,13 +2098,23 @@ async fn collect_status_uncached( for (remote_name, remote_ty, result) in results { let Ok(node_infos) = result else { continue }; for (node_name, node_info) in &node_infos { - let (status, level, sockets, current_key) = match node_info { - Some(info) => (info.status, info.level, info.sockets, info.key.clone()), + let (status, level, sockets, current_key, check_time, next_due_date) = match node_info + { + Some(info) => ( + info.status, + info.level, + info.sockets, + info.key.clone(), + info.check_time, + info.next_due_date.clone(), + ), None => ( proxmox_subscription::SubscriptionStatus::NotFound, SubscriptionLevel::None, None, None, + None, + None, ), }; out.push(RemoteNodeStatus { @@ -2015,6 +2127,8 @@ async fn collect_status_uncached( assigned_key: None, current_key, pending_clear: false, + check_time, + next_due_date, }); } } @@ -2098,6 +2212,8 @@ mod tests { assigned_key: None, current_key: None, pending_clear: false, + check_time: None, + next_due_date: None, } } diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs index b84ddb36..1a70013c 100644 --- a/ui/src/configuration/subscription_registry.rs +++ b/ui/src/configuration/subscription_registry.rs @@ -7,6 +7,7 @@ use anyhow::Error; use yew::virtual_dom::{Key, VComp, VNode}; use proxmox_yew_comp::percent_encoding::percent_encode_component; +use proxmox_yew_comp::utils::render_epoch; use proxmox_yew_comp::{http_delete, http_get, http_get_full, http_post}; use proxmox_yew_comp::{ LoadableComponent, LoadableComponentContext, LoadableComponentMaster, @@ -64,6 +65,26 @@ fn subscription_status_label(status: proxmox_subscription::SubscriptionStatus) - } } +/// Build a multi-line Status-column tooltip listing the last-check timestamp and the +/// next-due-date when the remote provides them. Returns None if neither is set so the caller +/// can skip wrapping the cell in a tooltip entirely. +fn status_tooltip_lines(n: &RemoteNodeStatus) -> Option { + let mut lines: Vec = Vec::new(); + if let Some(ts) = n.check_time { + lines.push(tr!("Last checked: {when}", when = render_epoch(ts))); + } + if let Some(due) = n.next_due_date.as_deref() { + if !due.is_empty() { + lines.push(tr!("Next due: {date}", date = due.to_string())); + } + } + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + fn pending_badge(push_count: u32, clear_count: u32) -> Row { let mut row = Row::new().class(AlignItems::Center).gap(3); if push_count > 0 { @@ -248,6 +269,9 @@ pub enum Msg { AdoptKeyForSelectedNode, /// Open the confirmation dialog for adopting every foreign live subscription into the pool. AdoptAllPreview, + /// Re-check the subscription on the currently-selected node against the shop. Pure refresh + /// path; no confirmation dialog since the action is read-only from the pool's perspective. + CheckSubscriptionForSelectedNode, } #[derive(PartialEq)] @@ -385,12 +409,16 @@ impl SubscriptionRegistryComp { node_field_sorter(a, b, |n| subscription_status_label(n.status)) }) .render(|entry: &NodeTreeEntry| match entry { - NodeTreeEntry::Node { data: n, .. } => Row::new() - .class(AlignItems::Baseline) - .gap(2) - .with_child(subscription_status_icon(n.status)) - .with_child(subscription_status_label(n.status)) - .into(), + NodeTreeEntry::Node { data: n, .. } => { + let row = Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(subscription_status_icon(n.status)) + .with_child(subscription_status_label(n.status)); + status_tooltip_lines(n) + .map(|tip| Tooltip::new(row.clone()).tip(tip).into()) + .unwrap_or_else(|| row.into()) + } NodeTreeEntry::Remote { active, total, .. } => { let icon = if active == total { Fa::new("check-circle").class(FontColor::Success) @@ -736,6 +764,23 @@ impl LoadableComponent for SubscriptionRegistryComp { ctx.link() .change_view(Some(ViewState::ConfirmAdoptAll { candidates })); } + Msg::CheckSubscriptionForSelectedNode => { + let Some(n) = self.selected_node_status() else { + return false; + }; + let remote = n.remote.clone(); + let node = n.node.clone(); + let link = ctx.link().clone(); + ctx.link().spawn(async move { + if let Err(err) = crate::pdm_client() + .subscription_check(&remote, &node) + .await + { + link.show_error(tr!("Check Subscription"), err.to_string(), true); + } + link.send_reload(); + }); + } } true } @@ -1087,6 +1132,12 @@ impl SubscriptionRegistryComp { let can_revert = self.revert_target().is_some(); let can_clear_key = self.selected_node_for_clear().is_some(); let can_adopt_key = self.selected_node_for_adopt().is_some(); + // Check Subscription is a no-op on the remote when no key is installed (PVE / PBS + // `update_subscription` returns early without contacting the shop), so disable the + // button to keep the UI honest about what clicking it will do. + let can_check = self + .selected_node_status() + .is_some_and(|n| n.status != proxmox_subscription::SubscriptionStatus::NotFound); let assign_button = Tooltip::new( Button::new(tr!("Assign Key")) .icon_class("fa fa-link") @@ -1126,6 +1177,20 @@ impl SubscriptionRegistryComp { .tip(tr!( "Import the live subscription on the selected node into the pool." )); + let check_button = Tooltip::new( + Button::new(tr!("Check Subscription")) + .icon_class("fa fa-refresh") + .disabled(!can_check) + .on_activate( + ctx.link() + .callback(|_| Msg::CheckSubscriptionForSelectedNode), + ), + ) + .tip(if can_check { + tr!("Re-verify the live subscription against the shop, refreshing the status.") + } else { + tr!("No subscription installed on the selected node; assign or adopt one first.") + }); Panel::new() .class(FlexFit) @@ -1137,6 +1202,7 @@ impl SubscriptionRegistryComp { .with_tool(adopt_key_button) .with_tool(revert_button) .with_tool(clear_key_button) + .with_tool(check_button) .with_child(table) } -- 2.47.3