From: Thomas Lamprecht <t.lamprecht@proxmox.com>
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 [thread overview]
Message-ID: <20260515074623.766766-12-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260515074623.766766-1-t.lamprecht@proxmox.com>
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 <t.lamprecht@proxmox.com>
---
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<String>) -> 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<String>,
+
+ /// Epoch of the last successful subscription check on the node.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub check_time: Option<i64>,
+
+ /// Next due date of the subscription, as reported by the remote.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub next_due_date: Option<String>,
}
#[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<i64>,
+ /// Next due date of the subscription, as reported by the remote.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub next_due_date: Option<String>,
}
#[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<T: HttpApiClient> PdmClient<T> {
.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<String> {
+ let mut lines: Vec<String> = 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
next prev parent reply other threads:[~2026-05-15 7:47 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 ` [PATCH datacenter-manager v3 06/12] cli: client: add subscription key pool management subcommands Thomas Lamprecht
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 ` Thomas Lamprecht [this message]
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-12-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.