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 E8F9F1FF14C for ; Fri, 15 May 2026 09:46:56 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id CF323E5C6; Fri, 15 May 2026 09:46:56 +0200 (CEST) From: Thomas Lamprecht To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v3 09/12] subscription: add Adopt Key action for foreign live subscriptions Date: Fri, 15 May 2026 09:43:19 +0200 Message-ID: <20260515074623.766766-10-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: 1778831192813 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.147 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 POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes 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: GQAIWFFTCKVTRK6G5K6ZWHFBMK7NAKPG X-Message-ID-Hash: GQAIWFFTCKVTRK6G5K6ZWHFBMK7NAKPG 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: Add a dedicated endpoint plus CLI / UI wiring for importing a remote node's live subscription into the pool as a bound entry, without touching the remote. The action covers the case where a key was already installed on a node before PDM took over its pool management; bringing it under the registry is required for any subsequent pool action to operate on it. Three sub-cases for the live key: - Not in the pool: insert with source=Adopted, bound to (remote, node). - In the pool but unbound: rebind, leaving the source field as-is so a key originally added by hand keeps its Manual label. - In the pool but bound elsewhere: refused, the operator has to reconcile the binding first. The endpoint pre-fetches the pool digest before the live network read and refuses with CONFLICT on mismatch, so a parallel set_assignment landing during the .await cannot silently rebind the key. Per-remote PRIV_RESOURCE_MODIFY is enforced inside the handler so operators with only global system access cannot pull subscriptions off remotes they have no other authority on. The Node Subscription Status tree marks adoptable rows (live key set, no pool binding yet) with a download hint icon so the action is discoverable without consulting the docs. The pool grid gets a new Source column exposing the Manual vs Adopted origin, hidden by default; available via the column picker. Signed-off-by: Thomas Lamprecht --- New in v3. cli/client/src/subscriptions.rs | 35 ++++ docs/subscription-registry.rst | 8 + lib/pdm-api-types/src/subscription.rs | 3 + lib/pdm-api-types/tests/test_import.rs | 29 +++ lib/pdm-client/src/lib.rs | 36 +++- server/src/api/subscriptions/mod.rs | 167 ++++++++++++++++++ ui/src/configuration/subscription_keys.rs | 16 +- ui/src/configuration/subscription_registry.rs | 107 ++++++++++- 8 files changed, 396 insertions(+), 5 deletions(-) diff --git a/cli/client/src/subscriptions.rs b/cli/client/src/subscriptions.rs index b9172a2e..c9ba5e4c 100644 --- a/cli/client/src/subscriptions.rs +++ b/cli/client/src/subscriptions.rs @@ -48,6 +48,10 @@ pub fn cli() -> CommandLineInterface { "revert-clear", CliCommand::new(&API_METHOD_REVERT_CLEAR).arg_param(&["remote", "node"]), ) + .insert( + "adopt-key", + CliCommand::new(&API_METHOD_ADOPT_KEY).arg_param(&["remote", "node"]), + ) .into() } @@ -308,6 +312,37 @@ async fn auto_assign(apply: bool) -> Result<(), Error> { Ok(()) } +#[api( + input: { + properties: { + remote: { schema: REMOTE_ID_SCHEMA }, + node: { schema: NODE_SCHEMA }, + digest: { + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + optional: true, + }, + }, + }, +)] +/// Adopt the live subscription on a remote node into the pool. +/// +/// Brings a foreign subscription under PDM management without touching the remote: the live +/// current key on `remote`/`node` is imported as a pool entry bound to that node. Refuses if +/// the (remote, node) target already has a pool-managed binding. +async fn adopt_key( + remote: String, + node: String, + digest: Option, +) -> Result<(), Error> { + let digest = digest.map(ConfigDigest::from); + client()? + .subscription_adopt_key(&remote, &node, digest) + .await?; + println!("Adopted live subscription on {remote}/{node} into the pool."); + Ok(()) +} + + #[api( input: { properties: { diff --git a/docs/subscription-registry.rst b/docs/subscription-registry.rst index 68b879be..4c31c9a6 100644 --- a/docs/subscription-registry.rst +++ b/docs/subscription-registry.rst @@ -44,6 +44,14 @@ issues the removal on the remote and releases the pool binding so the key become for reassignment. Discard Pending drops the queued clear without touching the remote; the binding stays intact and the operator can retry. +The Adopt Key action imports the live subscription on a remote node into the pool as a +bound entry, without touching the remote. Use it to bring a pre-existing subscription -- one +installed on a node before PDM took over its pool management -- under the registry so that +pool actions such as Clear Key and Auto-Assign can act on it. Nodes that are eligible for +adoption are highlighted with a download hint icon in the Node Subscription Status tree; +the pool grid carries a hidden-by-default Source column distinguishing manually-added from +adopted entries, which can be enabled via the column picker if the distinction matters. + The proposed plan can be inspected before it is applied. Apply Pending walks the queue in order; if any push or clear fails the remaining queue is kept intact for retry. Discard Pending drops the plan without touching any remote. diff --git a/lib/pdm-api-types/src/subscription.rs b/lib/pdm-api-types/src/subscription.rs index 7d3c8436..8a0a7977 100644 --- a/lib/pdm-api-types/src/subscription.rs +++ b/lib/pdm-api-types/src/subscription.rs @@ -310,6 +310,9 @@ pub enum SubscriptionKeySource { /// UI or CLI, and as the `serde(default)` for entries that predate this field. #[default] Manual, + /// Imported from a remote node's live subscription via the Adopt Key action, that is, a key + /// that was already installed on a remote before PDM took over its pool management. + Adopted, } #[api( diff --git a/lib/pdm-api-types/tests/test_import.rs b/lib/pdm-api-types/tests/test_import.rs index 33601620..72177460 100644 --- a/lib/pdm-api-types/tests/test_import.rs +++ b/lib/pdm-api-types/tests/test_import.rs @@ -40,6 +40,35 @@ fn entry_roundtrip() { assert_eq!(back.next_due_date.as_deref(), Some("2027-06-01")); } +#[test] +fn adopted_entry_roundtrip() { + // Ensure SubscriptionKeySource::Adopted serializes to its kebab-case form `adopted` and + // parses back to the same variant, so an in-place upgrade does not silently rewrite + // adopted pool entries to Manual on the next save. + let mut config = SectionConfigData::::default(); + config.insert( + "pbsc-1122334455".to_string(), + SubscriptionKeyEntry { + key: "pbsc-1122334455".to_string(), + product_type: ProductType::Pbs, + source: SubscriptionKeySource::Adopted, + remote: Some("backup-cluster".to_string()), + node: Some("pbs-1".to_string()), + ..Default::default() + }, + ); + + let raw = SubscriptionKeyEntry::write_section_config("test", &config).expect("write failed"); + assert!( + raw.contains("\tsource adopted"), + "expected kebab-case `adopted` in serialised form, got:\n{raw}", + ); + let parsed = SubscriptionKeyEntry::parse_section_config("test", &raw).expect("parse failed"); + let back = parsed.get("pbsc-1122334455").expect("key not found"); + assert_eq!(back.source, SubscriptionKeySource::Adopted); + assert_eq!(back.remote.as_deref(), Some("backup-cluster")); +} + #[test] fn shadow_roundtrip() { let mut shadow = SectionConfigData::::default(); diff --git a/lib/pdm-client/src/lib.rs b/lib/pdm-client/src/lib.rs index 530f2b5b..6c764c00 100644 --- a/lib/pdm-client/src/lib.rs +++ b/lib/pdm-client/src/lib.rs @@ -1273,9 +1273,41 @@ impl PdmClient { .data) } + /// Adopt the live subscription on `remote`/`node` into the pool: imports the live key as a + /// new pool entry bound to (remote, node) without touching the remote. Refuses if (remote, + /// node) already has a pool entry bound to it. See the server endpoint docs for the full + /// per-sub-case semantics (existing-unbound, existing-bound-elsewhere, not-in-pool). + pub async fn subscription_adopt_key( + &self, + remote: &str, + node: &str, + digest: Option, + ) -> Result<(), Error> { + #[derive(Serialize)] + struct AdoptArgs<'a> { + remote: &'a str, + node: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + digest: Option, + } + self.0 + .post( + "/api2/extjs/subscriptions/adopt-key", + &AdoptArgs { + remote, + node, + digest, + }, + ) + .await? + .nodata() + } + /// Queue a clear for the subscription on `remote`/`node`. Apply Pending later removes the - /// subscription from the node so the key can be reassigned elsewhere; Discard Pending undoes - /// the queueing without touching the remote. + /// subscription from the node so the key can be reassigned elsewhere; Discard Pending + /// undoes the queueing without touching the remote. Returns `BAD_REQUEST` if no pool entry + /// is bound to (remote, node); callers must run Adopt Key first to import a foreign + /// subscription. pub async fn subscription_queue_clear( &self, remote: &str, diff --git a/server/src/api/subscriptions/mod.rs b/server/src/api/subscriptions/mod.rs index 9c313e8c..cc46806c 100644 --- a/server/src/api/subscriptions/mod.rs +++ b/server/src/api/subscriptions/mod.rs @@ -39,6 +39,7 @@ pub const ROUTER: Router = Router::new() #[sortable] const SUBDIRS: SubdirMap = &sorted!([ + ("adopt-key", &Router::new().post(&API_METHOD_ADOPT_KEY)), ( "apply-pending", &Router::new().post(&API_METHOD_APPLY_PENDING) @@ -90,6 +91,11 @@ const PANEL_NODE_STATUS_MAX_AGE: u64 = 5 * 60; /// Keeps the product prefix and the first/last hex characters of the secret so an operator can /// still tell two keys apart in a tail of `journalctl`, but the full key never lands in a log /// file readable by anyone other than the priv user. +/// +/// Uses `chars()` rather than byte slicing so a hostile remote returning a non-ASCII subscription +/// key cannot trigger a slice-on-non-char-boundary panic; schema-validated pool keys are pure +/// ASCII per `PRODUCT_KEY_REGEX`, but `redact_key` is also reached by the adoption path on a +/// live key the remote owned, which can be any string. fn redact_key(key: &str) -> String { let Some((prefix, secret)) = key.split_once('-') else { return "".to_string(); @@ -954,6 +960,158 @@ 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 }, + digest: { + type: ConfigDigest, + optional: true, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system"], PRIV_SYS_MODIFY, false), + }, +)] +/// Adopt the live subscription on a remote node into the pool. +/// +/// Reads the live current key from `remote`/`node` and brings the pool under management of it +/// without touching the remote (no DELETE / push). Three sub-cases for the live key: +/// +/// - Not in the pool: a fresh `Adopted` entry is inserted, bound to (remote, node). +/// - In the pool, unbound: rebound to (remote, node); the source is left untouched so a key +/// that was originally added manually keeps its `Manual` label even after a remote re-import. +/// - In the pool, bound elsewhere: refused; the operator has to reconcile the binding first. +/// +/// Refuses if a pool entry is already bound to (remote, node): adopting a node that is already +/// pool-managed would either be a no-op or a footgun (rebinding the same node to a different +/// key in the pool), so the caller has to pick the right Assign/Clear path explicitly. +/// +/// Per-remote `PRIV_RESOURCE_MODIFY` is enforced inside the handler so an operator with global +/// system access alone cannot pull subscriptions off remotes they have no other authority on +/// (an adopted key bound to (remote, node) is itself an audit-side surface against that node). +async fn adopt_key( + remote: String, + node: String, + digest: Option, + 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, + )?; + + // Pre-fetch digest to catch a parallel set_assignment during the live read below. + let (_pre_config, pre_digest) = pdm_config::subscriptions::config()?; + + // Fetch live state before grabbing the config lock so the network call does not pin the + // lock for the duration of a remote query. + 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 live = get_subscription_info_for_remote(remote_entry, FRESH_NODE_STATUS_MAX_AGE) + .await + .map_err(|err| { + http_err!( + BAD_REQUEST, + "could not read subscription on {remote}/{node}: {err}" + ) + })?; + let live_current_key: String = live + .get(&node) + .and_then(|info| info.as_ref()) + .and_then(|info| info.key.clone()) + .ok_or_else(|| http_err!(NOT_FOUND, "no live subscription on {remote}/{node} to adopt"))?; + + // The lock + sync IO runs on a blocking thread so the async runtime stays free for other + // work even when /etc/proxmox-datacenter-manager/subscriptions is on slow storage. + let new_digest = tokio::task::spawn_blocking(move || -> Result { + let _lock = pdm_config::subscriptions::lock_config()?; + let (mut config, config_digest) = pdm_config::subscriptions::config()?; + config_digest.detect_modification(digest.as_ref())?; + if config_digest != pre_digest { + http_bail!( + CONFLICT, + "pool config changed during live fetch; refresh and retry adopt of \ + {remote}/{node}" + ); + } + + let target_bound = config.iter().any(|(_, e)| { + e.remote.as_deref() == Some(remote.as_str()) + && e.node.as_deref() == Some(node.as_str()) + }); + if target_bound { + http_bail!( + BAD_REQUEST, + "{remote}/{node} is already pool-managed; adopt only applies to foreign \ + subscriptions" + ); + } + + if let Some(existing) = config.get_mut(&live_current_key) { + if existing.remote.is_some() || existing.node.is_some() { + http_bail!( + CONFLICT, + "key '{}' is in the pool but bound elsewhere; resolve manually first", + redact_key(&live_current_key), + ); + } + existing.remote = Some(remote.clone()); + existing.node = Some(node.clone()); + } else { + // Schema-validate the live key before letting it touch the on-disk pool. The + // remote claimed it via /nodes/{node}/subscription, but that surface is not a + // strict-schema gate (older PVE versions accept whatever the operator typed at + // setup time), so re-validate here against the same schema that manual entry + // uses. + SUBSCRIPTION_KEY_SCHEMA + .parse_simple_value(&live_current_key) + .map_err(|err| { + http_err!( + BAD_REQUEST, + "key '{}' rejected: {err}", + redact_key(&live_current_key), + ) + })?; + let product_type = ProductType::from_key(&live_current_key).ok_or_else(|| { + http_err!( + BAD_REQUEST, + "unrecognised key prefix: {}", + redact_key(&live_current_key), + ) + })?; + let entry = SubscriptionKeyEntry { + key: live_current_key.clone(), + product_type, + level: SubscriptionLevel::from_key(Some(&live_current_key)), + source: SubscriptionKeySource::Adopted, + remote: Some(remote.clone()), + node: Some(node.clone()), + ..Default::default() + }; + config.insert(live_current_key, entry); + } + + pdm_config::subscriptions::save_config(&config) + }) + .await??; + rpcenv["digest"] = new_digest.to_hex().into(); + Ok(()) +} + #[api( input: { properties: { @@ -1723,6 +1881,15 @@ mod tests { assert_eq!(redact_key("pbsc-abcdef0123"), "pbsc-a...3"); } + #[test] + fn redact_key_safe_on_non_ascii_secret() { + // Slicing by byte index on a UTF-8 boundary would panic; chars()-based redaction must + // tolerate hostile / buggy remote inputs in the foreign-key adoption path. + let key = "pve4b-1\u{1F600}"; + let redacted = redact_key(key); + assert!(redacted.starts_with("pve4b-1...")); + } + #[test] fn redact_key_safe_on_single_char_secret() { assert_eq!(redact_key("pve4b-x"), "pve4b-x..."); diff --git a/ui/src/configuration/subscription_keys.rs b/ui/src/configuration/subscription_keys.rs index 5807504d..cff13563 100644 --- a/ui/src/configuration/subscription_keys.rs +++ b/ui/src/configuration/subscription_keys.rs @@ -6,7 +6,7 @@ use anyhow::Error; use pdm_api_types::remotes::RemoteType; use pdm_api_types::subscription::{ - ProductType, RemoteNodeStatus, SubscriptionKeyEntry, + ProductType, RemoteNodeStatus, SubscriptionKeyEntry, SubscriptionKeySource, }; use yew::virtual_dom::{Key, VComp, VNode}; @@ -123,8 +123,9 @@ impl SubscriptionKeyGridComp { Rc::new(vec![ DataTableColumn::new(tr!("Key")) .flex(2) - .get_property(|entry: &SubscriptionKeyEntry| entry.key.as_str()) + .sorter(|a: &SubscriptionKeyEntry, b: &SubscriptionKeyEntry| a.key.cmp(&b.key)) .sort_order(true) + .render(|entry: &SubscriptionKeyEntry| entry.key.as_str().into()) .into(), DataTableColumn::new(tr!("Product")) .width("80px") @@ -140,6 +141,17 @@ impl SubscriptionKeyGridComp { .sorter(|a: &SubscriptionKeyEntry, b: &SubscriptionKeyEntry| a.level.cmp(&b.level)) .render(|entry: &SubscriptionKeyEntry| entry.level.to_string().into()) .into(), + DataTableColumn::new(tr!("Source")) + .width("90px") + .hidden(true) + .sorter(|a: &SubscriptionKeyEntry, b: &SubscriptionKeyEntry| { + (a.source as u8).cmp(&(b.source as u8)) + }) + .render(|entry: &SubscriptionKeyEntry| match entry.source { + SubscriptionKeySource::Manual => tr!("Manual").into(), + SubscriptionKeySource::Adopted => tr!("Adopted").into(), + }) + .into(), DataTableColumn::new(tr!("Assignment")) .flex(2) .sorter(|a: &SubscriptionKeyEntry, b: &SubscriptionKeyEntry| { diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs index 7471fae4..7d79370b 100644 --- a/ui/src/configuration/subscription_registry.rs +++ b/ui/src/configuration/subscription_registry.rs @@ -235,6 +235,9 @@ pub enum Msg { QueueClearForSelectedNode, /// Open the Assign Key dialog for the currently-selected node. AssignKeyToSelectedNode, + /// Open the confirmation dialog for adopting the live subscription on the selected node + /// into the pool. + AdoptKeyForSelectedNode, } #[derive(PartialEq)] @@ -249,6 +252,13 @@ pub enum ViewState { node: String, current_key: Option, }, + /// Pending confirmation to adopt the live subscription on `(remote, node)` into the pool. + /// The live key is captured here so the dialog body can show what will be imported. + ConfirmAdoptKey { + remote: String, + node: String, + current_key: String, + }, /// Assign a pool key to the given node. Opens from the right panel's Assign Key button. AssignKeyToNode { remote: String, @@ -494,6 +504,18 @@ fn key_cell(n: &RemoteNodeStatus) -> Html { .with_child(Fa::new("clock-o").class(FontColor::Warning)) .with_child(text) .into() + } else if assigned.is_none() && current.is_some() { + Tooltip::new( + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new("download").class(FontColor::Primary)) + .with_child(text), + ) + .tip(tr!( + "Not in pool - Adopt Key imports this live subscription." + )) + .into() } else { text.into() } @@ -660,6 +682,16 @@ impl LoadableComponent for SubscriptionRegistryComp { node_sockets, })); } + Msg::AdoptKeyForSelectedNode => { + let Some((remote, node, current_key)) = self.selected_node_for_adopt() else { + return false; + }; + ctx.link().change_view(Some(ViewState::ConfirmAdoptKey { + remote, + node, + current_key, + })); + } } true } @@ -828,6 +860,55 @@ impl LoadableComponent for SubscriptionRegistryComp { ViewState::ConfirmAutoAssign(proposal) => { Some(self.render_auto_assign_dialog(ctx, proposal)) } + ViewState::ConfirmAdoptKey { + remote, + node, + current_key, + } => { + use pwt::widget::ConfirmDialog; + let question = tr!( + "Adopt {key} from {remote}/{node} into the pool?", + key = current_key.clone(), + remote = remote.clone(), + node = node.clone(), + ); + let body = Column::new() + .gap(2) + .with_child(Container::from_tag("p").with_child(question)) + .with_child(Container::from_tag("p").with_child(tr!( + "The live subscription is imported as a pool entry bound to this node; the remote is not contacted. After adoption the key participates in pool operations such as Clear Key and Auto-Assign." + ))); + let remote_for_cb = remote.clone(); + let node_for_cb = node.clone(); + let link = ctx.link().clone(); + let close_link = ctx.link().clone(); + let digest_for_cb = self.pool_digest.clone(); + Some( + ConfirmDialog::default() + .title(tr!("Adopt Key")) + .icon_class("fa fa-question-circle") + .confirm_message(body) + .on_confirm(move |_| { + let link = link.clone(); + let remote = remote_for_cb.clone(); + let node = node_for_cb.clone(); + let digest = digest_for_cb.clone(); + link.clone().spawn(async move { + let digest = digest.map(pdm_client::ConfigDigest::from); + if let Err(err) = crate::pdm_client() + .subscription_adopt_key(&remote, &node, digest) + .await + { + link.show_error(tr!("Adopt Key"), err.to_string(), true); + } + link.change_view(None); + link.send_reload(); + }); + }) + .on_close(move |_| close_link.change_view(None)) + .into(), + ) + } ViewState::ConfirmQueueClear { remote, node, @@ -945,6 +1026,7 @@ impl SubscriptionRegistryComp { let can_assign_key = self.assign_target_for_selected_node().is_some(); 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(); let assign_button = Tooltip::new( Button::new(tr!("Assign Key")) .icon_class("fa fa-link") @@ -973,7 +1055,16 @@ impl SubscriptionRegistryComp { .tip(tr!( "Queue the live subscription on the selected node for removal at next Apply \ Pending, freeing the key for reassignment. Requires the node to be \ - pool-managed." + pool-managed; for foreign subscriptions, run Adopt Key first." + )); + let adopt_key_button = Tooltip::new( + Button::new(tr!("Adopt Key")) + .icon_class("fa fa-download") + .disabled(!can_adopt_key) + .on_activate(ctx.link().callback(|_| Msg::AdoptKeyForSelectedNode)), + ) + .tip(tr!( + "Import the live subscription on the selected node into the pool." )); Panel::new() @@ -983,6 +1074,7 @@ impl SubscriptionRegistryComp { .min_width(400) .title(tr!("Node Subscription Status")) .with_tool(assign_button) + .with_tool(adopt_key_button) .with_tool(revert_button) .with_tool(clear_key_button) .with_child(table) @@ -1070,6 +1162,19 @@ impl SubscriptionRegistryComp { Some((n.remote.clone(), n.node.clone(), n.current_key.clone())) } + /// Returns `(remote, node, current_key)` when the selected node has a foreign live + /// subscription eligible for Adopt Key: a current key is set on the node and no pool entry + /// is bound to (remote, node) yet. Mutually exclusive with `selected_node_for_clear` so the + /// toolbar can offer exactly one of Clear Key / Adopt Key for any given selection. + fn selected_node_for_adopt(&self) -> Option<(String, String, String)> { + let n = self.selected_node_status()?; + if n.assigned_key.is_some() { + return None; + } + let current_key = n.current_key.clone()?; + Some((n.remote.clone(), n.node.clone(), current_key)) + } + /// Returns `(remote, node, type, node_sockets)` for the right-panel Assign button: /// selected row is a node, no assigned key in the pool yet, and no live active subscription. /// Refusing earlier than the server keeps the button-disable affordance honest. -- 2.47.3