Skip to main content

bamboo_config/
cluster_fabric.rs

1//! Remote Cluster Fabric configuration: operator-managed nodes & clusters.
2//!
3//! A **node** is one machine (local or SSH-reachable) that bamboo can deploy a
4//! `broker-agent` worker onto; a **cluster** is a named group of node ids used
5//! for disclosure/grouping. This is the L1 *operator* data model (RFC v2 §3):
6//! persistent, additive, back-compat (absent ⇒ empty).
7//!
8//! Secrets (SSH password / private key / passphrase) are encrypted at rest with
9//! the same AES-256-GCM pattern as [`crate::config::EnvVarEntry`]: a plaintext
10//! field hydrated in memory, an `*_encrypted` field on disk, plaintext cleared
11//! before serialization. See [`Config::hydrate_cluster_fabric_from_encrypted`],
12//! [`Config::refresh_cluster_fabric_encrypted`],
13//! [`Config::sanitize_cluster_fabric_for_disk`].
14//!
15//! NOTE: the deploy engine (P2) is not wired here — `NodeState` is engine-owned
16//! and stays `None` until a deploy runs. This module is purely the persisted
17//! registry + its crypto.
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use anyhow::{Context, Result};
22use serde::{Deserialize, Serialize};
23
24use crate::config::Config;
25use crate::config_store::ConfigStoreResult;
26use crate::credential_store::{credential_ref, CredentialRef};
27
28/// The persisted cluster fabric: clusters (groups) + nodes (machines).
29///
30/// Additive and back-compat: an absent `cluster_fabric` key deserializes to the
31/// empty default and never appears on disk (`skip_serializing_if`).
32#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
33pub struct ClusterFabricConfig {
34    /// Named groups of node ids (the disclosure/grouping unit).
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub clusters: Vec<Cluster>,
37    /// The registered machines.
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub nodes: Vec<Node>,
40    /// Stable references to SSH secrets, keyed by the node's immutable id.
41    ///
42    /// Runtime plaintext remains in [`SshAuth`], while ordinary configuration
43    /// persists only these references and truthful configured metadata. The
44    /// legacy `*_encrypted` fields remain readable during migration but are not
45    /// the authority for newly isolated credentials.
46    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47    pub credential_refs: BTreeMap<String, ClusterNodeCredentialRefs>,
48    /// Seconds between background health probes of Running/Unreachable nodes.
49    /// Omitted → the built-in default (30s); `0` disables the health monitor.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub health_interval_secs: Option<u64>,
52}
53
54/// Default health-probe cadence when `health_interval_secs` is unset.
55pub const DEFAULT_HEALTH_INTERVAL_SECS: u64 = 30;
56
57impl ClusterFabricConfig {
58    /// True when there are no clusters and no nodes (the serialize-skip gate).
59    pub fn is_empty(&self) -> bool {
60        self.clusters.is_empty() && self.nodes.is_empty() && self.credential_refs.is_empty()
61    }
62
63    /// Resolve the health-monitor cadence: `None` when disabled (`0`), else the
64    /// configured seconds or the [`DEFAULT_HEALTH_INTERVAL_SECS`] default.
65    pub fn health_interval(&self) -> Option<std::time::Duration> {
66        match self.health_interval_secs {
67            Some(0) => None,
68            Some(s) => Some(std::time::Duration::from_secs(s)),
69            None => Some(std::time::Duration::from_secs(DEFAULT_HEALTH_INTERVAL_SECS)),
70        }
71    }
72
73    /// Look up a node by id.
74    pub fn node(&self, id: &str) -> Option<&Node> {
75        self.nodes.iter().find(|n| n.id == id)
76    }
77
78    /// Mutable node lookup by id.
79    pub fn node_mut(&mut self, id: &str) -> Option<&mut Node> {
80        self.nodes.iter_mut().find(|n| n.id == id)
81    }
82
83    /// Look up a cluster by name.
84    pub fn cluster(&self, name: &str) -> Option<&Cluster> {
85        self.clusters.iter().find(|c| c.name == name)
86    }
87
88    /// Drop metadata for nodes that no longer exist. Credential refs remain an
89    /// inert persistence seam until the cluster exact transaction is wired;
90    /// callers must prune them whenever a node collection is replaced.
91    pub fn prune_orphaned_credential_refs(&mut self) {
92        let node_ids = self
93            .nodes
94            .iter()
95            .map(|node| node.id.as_str())
96            .collect::<std::collections::BTreeSet<_>>();
97        self.credential_refs
98            .retain(|node_id, _| node_ids.contains(node_id.as_str()));
99    }
100}
101
102/// Metadata for one node's isolated SSH credentials.
103#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
104pub struct ClusterNodeCredentialRefs {
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub password_credential_ref: Option<CredentialRef>,
107    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
108    pub password_configured: bool,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub private_key_credential_ref: Option<CredentialRef>,
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub private_key_configured: bool,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub passphrase_credential_ref: Option<CredentialRef>,
115    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
116    pub passphrase_configured: bool,
117}
118
119impl std::fmt::Debug for ClusterNodeCredentialRefs {
120    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        formatter
122            .debug_struct("ClusterNodeCredentialRefs")
123            .field(
124                "password_reference_present",
125                &self.password_credential_ref.is_some(),
126            )
127            .field("password_configured", &self.password_configured)
128            .field(
129                "private_key_reference_present",
130                &self.private_key_credential_ref.is_some(),
131            )
132            .field("private_key_configured", &self.private_key_configured)
133            .field(
134                "passphrase_reference_present",
135                &self.passphrase_credential_ref.is_some(),
136            )
137            .field("passphrase_configured", &self.passphrase_configured)
138            .finish()
139    }
140}
141
142impl ClusterNodeCredentialRefs {
143    pub fn references(&self) -> impl Iterator<Item = &CredentialRef> {
144        [
145            self.password_credential_ref.as_ref(),
146            self.private_key_credential_ref.as_ref(),
147            self.passphrase_credential_ref.as_ref(),
148        ]
149        .into_iter()
150        .flatten()
151    }
152
153    pub fn is_empty(&self) -> bool {
154        self.references().next().is_none()
155            && !self.password_configured
156            && !self.private_key_configured
157            && !self.passphrase_configured
158    }
159}
160
161/// Explicit operator intent for one isolated cluster credential.
162///
163/// Deliberately does not implement `Debug` or serialization: replacement
164/// values are request-only secret material and must never enter ordinary
165/// configuration documents, logs, events, or diagnostics.
166#[derive(Clone, PartialEq, Eq)]
167pub enum ClusterCredentialAction {
168    Keep,
169    Replace(String),
170    Clear,
171}
172
173/// Explicit password/private-key/passphrase actions for one node mutation.
174///
175/// Every HTTP mutation supplies all three actions, so an omitted field can
176/// never ambiguously mean both "keep" and "clear".
177#[derive(Clone, PartialEq, Eq)]
178pub struct ClusterNodeCredentialIntents {
179    pub password: ClusterCredentialAction,
180    pub private_key: ClusterCredentialAction,
181    pub passphrase: ClusterCredentialAction,
182}
183
184impl ClusterNodeCredentialIntents {
185    pub fn clear_all() -> Self {
186        Self {
187            password: ClusterCredentialAction::Clear,
188            private_key: ClusterCredentialAction::Clear,
189            passphrase: ClusterCredentialAction::Clear,
190        }
191    }
192}
193
194pub fn cluster_password_credential_ref(node_id: &str) -> ConfigStoreResult<CredentialRef> {
195    credential_ref("cluster", node_id, "password")
196}
197
198pub fn cluster_private_key_credential_ref(node_id: &str) -> ConfigStoreResult<CredentialRef> {
199    credential_ref("cluster", node_id, "private_key")
200}
201
202pub fn cluster_passphrase_credential_ref(node_id: &str) -> ConfigStoreResult<CredentialRef> {
203    credential_ref("cluster", node_id, "passphrase")
204}
205
206/// A named group of node ids. Clusters carry no credentials — they are pure
207/// grouping for disclosure and operator organization.
208#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
209pub struct Cluster {
210    pub name: String,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub description: Option<String>,
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub node_ids: Vec<String>,
215}
216
217/// One machine: where it is (`placement`), how much we trust it (`trust_level`),
218/// what to launch (`deploy`), and engine-owned live `state`.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct Node {
221    /// Stable unique id (uuid). The `ask_agent`/dispatch handle the agent uses.
222    pub id: String,
223    /// Human label, e.g. "gpu-1".
224    pub label: String,
225    /// Local (localhost, no SSH) or Ssh (remote) — the ONLY local/remote diff.
226    pub placement: NodePlacement,
227    /// Default `Trusted` (own infra ⇒ ship creds). `Untrusted` ⇒ proxy-home (future).
228    #[serde(default)]
229    pub trust_level: TrustLevel,
230    /// What to launch + which artifact to upload.
231    #[serde(default)]
232    pub deploy: DeployProfile,
233    /// Engine-owned live state (status/worker/health). `None` until first deploy.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub state: Option<NodeState>,
236    /// Operator on/off switch (a disabled node is hidden from dispatch).
237    #[serde(default = "default_true")]
238    pub enabled: bool,
239}
240
241fn default_true() -> bool {
242    true
243}
244
245/// Where a node lives. `remote = local + {ssh connect, upload, reverse tunnel}`.
246#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
247#[serde(tag = "type", rename_all = "snake_case")]
248pub enum NodePlacement {
249    /// localhost → `LocalProcessDeployer`; no ssh/upload/tunnel.
250    #[default]
251    Local,
252    /// remote → russh/system-ssh deployer + binary upload + reverse tunnel.
253    Ssh(SshTarget),
254}
255
256/// Trust posture for credential handling (RFC v2 §7).
257#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
258#[serde(rename_all = "snake_case")]
259pub enum TrustLevel {
260    /// Own infra: OK to sync provider/MCP creds to the node (default).
261    #[default]
262    Trusted,
263    /// Future: proxy LLM/MCP calls home so no secret leaves the orchestrator.
264    Untrusted,
265}
266
267/// SSH connection target for a remote node. Secrets live in `auth`.
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
269pub struct SshTarget {
270    pub host: String,
271    #[serde(default = "default_ssh_port")]
272    pub port: u16,
273    pub username: String,
274    pub auth: SshAuth,
275    /// TOFU-pinned host key fingerprint; a changed key is rejected as MITM.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub host_key_fingerprint: Option<String>,
278}
279
280fn default_ssh_port() -> u16 {
281    22
282}
283
284/// How to authenticate the SSH connection. Secret material is encrypted at rest
285/// (the `*_encrypted` fields); plaintext is hydrated in memory and cleared
286/// before disk, exactly like [`crate::config::EnvVarEntry`].
287#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
288#[serde(tag = "method", rename_all = "snake_case")]
289pub enum SshAuth {
290    /// Use the bamboo host's own ssh agent/config (→ system-ssh deployer). No
291    /// stored secret — delegated to the host. This is the only auth method the
292    /// existing system-`ssh` `SshDeployer` can serve.
293    SystemSshConfig,
294    /// Stored password.
295    Password {
296        /// Plaintext — hydrated in memory, empty on disk.
297        #[serde(default, skip_serializing_if = "String::is_empty")]
298        password: String,
299        /// Ciphertext on disk.
300        #[serde(default, skip_serializing_if = "Option::is_none")]
301        password_encrypted: Option<String>,
302    },
303    /// Private key — either inline PEM (secret, encrypted) or an on-host path
304    /// (not a secret). An optional passphrase is always a secret.
305    PrivateKey {
306        /// Inline PEM plaintext — hydrated in memory, empty on disk.
307        #[serde(default, skip_serializing_if = "String::is_empty")]
308        private_key: String,
309        /// Ciphertext of the inline PEM on disk.
310        #[serde(default, skip_serializing_if = "Option::is_none")]
311        private_key_encrypted: Option<String>,
312        /// Path to a key file on the bamboo host (NOT a secret).
313        #[serde(default, skip_serializing_if = "Option::is_none")]
314        private_key_path: Option<String>,
315        /// Optional key passphrase plaintext — hydrated in memory, empty on disk.
316        #[serde(default, skip_serializing_if = "String::is_empty")]
317        passphrase: String,
318        /// Ciphertext of the passphrase on disk.
319        #[serde(default, skip_serializing_if = "Option::is_none")]
320        passphrase_encrypted: Option<String>,
321    },
322}
323
324impl std::fmt::Debug for SshAuth {
325    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        match self {
327            Self::SystemSshConfig => formatter.write_str("SystemSshConfig"),
328            Self::Password {
329                password,
330                password_encrypted,
331            } => formatter
332                .debug_struct("Password")
333                .field("password_configured", &!password.is_empty())
334                .field(
335                    "legacy_password_ciphertext_present",
336                    &password_encrypted.is_some(),
337                )
338                .finish(),
339            Self::PrivateKey {
340                private_key,
341                private_key_encrypted,
342                private_key_path,
343                passphrase,
344                passphrase_encrypted,
345            } => formatter
346                .debug_struct("PrivateKey")
347                .field("inline_private_key_configured", &!private_key.is_empty())
348                .field(
349                    "legacy_private_key_ciphertext_present",
350                    &private_key_encrypted.is_some(),
351                )
352                .field("private_key_path", private_key_path)
353                .field("passphrase_configured", &!passphrase.is_empty())
354                .field(
355                    "legacy_passphrase_ciphertext_present",
356                    &passphrase_encrypted.is_some(),
357                )
358                .finish(),
359        }
360    }
361}
362
363/// What to launch on the node + which artifact to upload (RFC v2 §6).
364#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
365pub struct DeployProfile {
366    /// Local-on-bamboo-host binary path to SFTP-upload (correct remote arch).
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub artifact_path: Option<String>,
369    /// Expected sha256 of the artifact (idempotent redeploy / integrity).
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub artifact_sha256: Option<String>,
372    /// Remote install dir (default `~/.bamboo-deploy`).
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub remote_dir: Option<String>,
375    /// Role the broker-agent runs as.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub default_role: Option<String>,
378    /// Model override for the deployed worker.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub model: Option<String>,
381    /// Workspace override for the deployed worker.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub workspace: Option<String>,
384    /// Auto-redeploy this node when the health monitor finds its worker gone
385    /// (opt-in; only recovers a node that WAS Running, never a user-Stopped one).
386    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
387    pub auto_recover: bool,
388}
389
390/// Engine-owned live state. Written by the deploy engine (P2), not the operator.
391#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
392pub struct NodeState {
393    pub status: NodeStatus,
394    /// Broker mailbox id (the `ask_agent` target) once deployed.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub worker_id: Option<String>,
397    /// Name of the encrypted env var holding this node's broker token.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub token_env: Option<String>,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub remote_pid: Option<u32>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub log_path: Option<String>,
404    /// RFC3339 timestamps + last error, all optional.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub deployed_at: Option<String>,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub last_health: Option<String>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub last_error: Option<String>,
411}
412
413/// Lifecycle status of a node's worker.
414#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
415#[serde(rename_all = "snake_case")]
416pub enum NodeStatus {
417    #[default]
418    NotDeployed,
419    Deploying,
420    Running,
421    Unreachable,
422    Stopped,
423    Failed,
424}
425
426// ── Crypto: mirror the env-vars AES-256-GCM at-rest pattern ────────────────
427
428impl Config {
429    /// Resolve isolated SSH credentials after the legacy migration has
430    /// completed. Metadata is server-owned: every reference must be the
431    /// canonical node-scoped reference for its auth field, must match the SSH
432    /// auth variant, and must not be shared with another configuration
433    /// consumer. Any validation or store failure leaves every cluster runtime
434    /// secret empty.
435    pub fn hydrate_cluster_credentials_from_store(
436        &mut self,
437        data_dir: &std::path::Path,
438    ) -> ConfigStoreResult<()> {
439        // Preserve the historical no-store-read path for clusters without
440        // credential references. The validator still runs so inconsistent
441        // configured flags, orphaned metadata, or stray plaintext fail closed.
442        if !self
443            .cluster_fabric
444            .credential_refs
445            .values()
446            .any(|metadata| metadata.references().next().is_some())
447        {
448            return self.hydrate_cluster_credentials(None);
449        }
450
451        let store = crate::CredentialStore::open(data_dir);
452        // One transaction lock performs readiness/recovery once and captures
453        // one immutable document for every cluster reference.
454        let (snapshot, _, _) = store.snapshot_with_health()?;
455        self.hydrate_cluster_credentials(Some(&snapshot))
456    }
457
458    /// Resolve one cluster runtime from an immutable credential document
459    /// captured under the credential migration lock. Compound transactions use
460    /// this exact snapshot before admitting a later cross-process writer.
461    pub(crate) fn hydrate_cluster_credentials_from_snapshot(
462        &mut self,
463        credentials: &crate::credential_store::CredentialDocumentLkg,
464    ) -> ConfigStoreResult<()> {
465        self.hydrate_cluster_credentials(Some(credentials))
466    }
467
468    fn hydrate_cluster_credentials(
469        &mut self,
470        credentials: Option<&crate::credential_store::CredentialDocumentLkg>,
471    ) -> ConfigStoreResult<()> {
472        #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
473        enum Field {
474            Password,
475            PrivateKey,
476            Passphrase,
477        }
478        let mut prior_plaintext = BTreeMap::<(usize, Field), String>::new();
479        let legacy_ciphertext_present =
480            self.cluster_fabric
481                .nodes
482                .iter()
483                .enumerate()
484                .any(|(index, node)| {
485                    let NodePlacement::Ssh(target) = &node.placement else {
486                        return false;
487                    };
488                    match &target.auth {
489                        SshAuth::SystemSshConfig => false,
490                        SshAuth::Password {
491                            password,
492                            password_encrypted,
493                        } => {
494                            if !password.trim().is_empty() {
495                                prior_plaintext.insert((index, Field::Password), password.clone());
496                            }
497                            password_encrypted
498                                .as_deref()
499                                .is_some_and(|value| !value.trim().is_empty())
500                        }
501                        SshAuth::PrivateKey {
502                            private_key,
503                            private_key_encrypted,
504                            passphrase,
505                            passphrase_encrypted,
506                            ..
507                        } => {
508                            if !private_key.trim().is_empty() {
509                                prior_plaintext
510                                    .insert((index, Field::PrivateKey), private_key.clone());
511                            }
512                            if !passphrase.trim().is_empty() {
513                                prior_plaintext
514                                    .insert((index, Field::Passphrase), passphrase.clone());
515                            }
516                            private_key_encrypted
517                                .as_deref()
518                                .is_some_and(|value| !value.trim().is_empty())
519                                || passphrase_encrypted
520                                    .as_deref()
521                                    .is_some_and(|value| !value.trim().is_empty())
522                        }
523                    }
524                });
525        self.clear_cluster_runtime_credentials();
526        if legacy_ciphertext_present {
527            return Err(crate::ConfigStoreError::Validation(
528                "legacy cluster credential appeared after migration".to_string(),
529            ));
530        }
531
532        let mut node_ids = BTreeSet::new();
533        for node in &self.cluster_fabric.nodes {
534            if node.id.trim().is_empty() {
535                return Err(crate::ConfigStoreError::Validation(
536                    "cluster node id is empty".to_string(),
537                ));
538            }
539            if !node_ids.insert(node.id.as_str()) {
540                return Err(crate::ConfigStoreError::Validation(
541                    "cluster node ids must be unique".to_string(),
542                ));
543            }
544        }
545        for node_id in self.cluster_fabric.credential_refs.keys() {
546            if !node_ids.contains(node_id.as_str()) {
547                return Err(crate::ConfigStoreError::Validation(
548                    "cluster credential metadata references an unknown node".to_string(),
549                ));
550            }
551        }
552
553        let mut requested = Vec::<(usize, Field, CredentialRef)>::new();
554        let mut seen_refs = BTreeSet::new();
555        let consumer_counts = crate::credential_store::config_credential_ref_counts(self)?;
556        for (index, node) in self.cluster_fabric.nodes.iter().enumerate() {
557            let metadata = self.cluster_fabric.credential_refs.get(&node.id);
558            let empty = ClusterNodeCredentialRefs::default();
559            let metadata = metadata.unwrap_or(&empty);
560            let mut validate = |field: Field,
561                                reference: Option<&CredentialRef>,
562                                configured: bool,
563                                canonical: CredentialRef|
564             -> ConfigStoreResult<()> {
565                if configured != reference.is_some() {
566                    return Err(crate::ConfigStoreError::Validation(
567                        "cluster credential configured metadata is inconsistent".to_string(),
568                    ));
569                }
570                let Some(reference) = reference else {
571                    return Ok(());
572                };
573                if reference != &canonical {
574                    return Err(crate::ConfigStoreError::Validation(
575                        "cluster credential reference is not canonical".to_string(),
576                    ));
577                }
578                // The shared inventory includes this cluster metadata slot.
579                // Exactly one consumer is therefore the expected exclusive
580                // state; any additional consumer remains fail-closed.
581                if consumer_counts.get(reference).copied().unwrap_or(0) != 1
582                    || !seen_refs.insert(reference.clone())
583                {
584                    return Err(crate::ConfigStoreError::Validation(
585                        "cluster credential reference is shared by another config consumer"
586                            .to_string(),
587                    ));
588                }
589                requested.push((index, field, reference.clone()));
590                Ok(())
591            };
592
593            match &node.placement {
594                NodePlacement::Local => {
595                    if !metadata.is_empty() {
596                        return Err(crate::ConfigStoreError::Validation(
597                            "local cluster node carries SSH credential metadata".to_string(),
598                        ));
599                    }
600                }
601                NodePlacement::Ssh(target) => match &target.auth {
602                    SshAuth::SystemSshConfig => {
603                        if !metadata.is_empty() {
604                            return Err(crate::ConfigStoreError::Validation(
605                                "system SSH node carries stored credential metadata".to_string(),
606                            ));
607                        }
608                    }
609                    SshAuth::Password { .. } => {
610                        if metadata.private_key_credential_ref.is_some()
611                            || metadata.private_key_configured
612                            || metadata.passphrase_credential_ref.is_some()
613                            || metadata.passphrase_configured
614                        {
615                            return Err(crate::ConfigStoreError::Validation(
616                                "cluster credential metadata does not match password auth"
617                                    .to_string(),
618                            ));
619                        }
620                        validate(
621                            Field::Password,
622                            metadata.password_credential_ref.as_ref(),
623                            metadata.password_configured,
624                            cluster_password_credential_ref(&node.id)?,
625                        )?;
626                    }
627                    SshAuth::PrivateKey { .. } => {
628                        if metadata.password_credential_ref.is_some()
629                            || metadata.password_configured
630                        {
631                            return Err(crate::ConfigStoreError::Validation(
632                                "cluster credential metadata does not match private-key auth"
633                                    .to_string(),
634                            ));
635                        }
636                        validate(
637                            Field::PrivateKey,
638                            metadata.private_key_credential_ref.as_ref(),
639                            metadata.private_key_configured,
640                            cluster_private_key_credential_ref(&node.id)?,
641                        )?;
642                        validate(
643                            Field::Passphrase,
644                            metadata.passphrase_credential_ref.as_ref(),
645                            metadata.passphrase_configured,
646                            cluster_passphrase_credential_ref(&node.id)?,
647                        )?;
648                    }
649                },
650            }
651        }
652
653        let mut resolved = Vec::with_capacity(requested.len());
654        for (index, field, reference) in requested {
655            let credentials = credentials.ok_or_else(|| {
656                crate::ConfigStoreError::Validation(
657                    "referenced cluster credential is unavailable".to_string(),
658                )
659            })?;
660            let secret = credentials.resolve(&reference)?.ok_or_else(|| {
661                crate::ConfigStoreError::Validation(
662                    "referenced cluster credential is unavailable".to_string(),
663                )
664            })?;
665            let secret = secret.expose().to_string();
666            if let Some(previous) = prior_plaintext.remove(&(index, field)) {
667                if previous != secret {
668                    return Err(crate::ConfigStoreError::Validation(
669                        "legacy cluster credential appeared after migration".to_string(),
670                    ));
671                }
672            }
673            resolved.push((index, field, secret));
674        }
675        if !prior_plaintext.is_empty() {
676            return Err(crate::ConfigStoreError::Validation(
677                "legacy cluster credential appeared after migration".to_string(),
678            ));
679        }
680        for (index, field, secret) in resolved {
681            let NodePlacement::Ssh(target) = &mut self.cluster_fabric.nodes[index].placement else {
682                unreachable!("validated SSH credential target")
683            };
684            match (&mut target.auth, field) {
685                (SshAuth::Password { password, .. }, Field::Password) => *password = secret,
686                (SshAuth::PrivateKey { private_key, .. }, Field::PrivateKey) => {
687                    *private_key = secret
688                }
689                (SshAuth::PrivateKey { passphrase, .. }, Field::Passphrase) => *passphrase = secret,
690                _ => unreachable!("validated cluster credential field"),
691            }
692        }
693        Ok(())
694    }
695
696    /// Remove legacy/cached cluster secrets from a runtime snapshot. Used both
697    /// before store hydration and when migration readiness is unavailable.
698    pub fn clear_cluster_runtime_credentials(&mut self) {
699        for node in &mut self.cluster_fabric.nodes {
700            let NodePlacement::Ssh(target) = &mut node.placement else {
701                continue;
702            };
703            match &mut target.auth {
704                SshAuth::SystemSshConfig => {}
705                SshAuth::Password {
706                    password,
707                    password_encrypted,
708                } => {
709                    password.clear();
710                    *password_encrypted = None;
711                }
712                SshAuth::PrivateKey {
713                    private_key,
714                    private_key_encrypted,
715                    passphrase,
716                    passphrase_encrypted,
717                    ..
718                } => {
719                    private_key.clear();
720                    *private_key_encrypted = None;
721                    passphrase.clear();
722                    *passphrase_encrypted = None;
723                }
724            }
725        }
726    }
727
728    /// Decrypt SSH secrets into in-memory plaintext after loading config.
729    ///
730    /// Mirrors [`Config::hydrate_env_vars_from_encrypted`]: only fills a
731    /// plaintext field that is currently empty, from its `*_encrypted`
732    /// counterpart.
733    pub fn hydrate_cluster_fabric_from_encrypted(&mut self) {
734        let credential_refs = self.cluster_fabric.credential_refs.clone();
735        for node in &mut self.cluster_fabric.nodes {
736            let metadata = credential_refs.get(&node.id);
737            let NodePlacement::Ssh(target) = &mut node.placement else {
738                continue;
739            };
740            match &mut target.auth {
741                SshAuth::SystemSshConfig => {}
742                SshAuth::Password {
743                    password,
744                    password_encrypted,
745                } => {
746                    if metadata.is_some_and(|value| value.password_credential_ref.is_some()) {
747                        *password_encrypted = None;
748                        continue;
749                    }
750                    hydrate_field(
751                        password,
752                        password_encrypted.as_deref(),
753                        &node.id,
754                        "password",
755                    );
756                }
757                SshAuth::PrivateKey {
758                    private_key,
759                    private_key_encrypted,
760                    passphrase,
761                    passphrase_encrypted,
762                    ..
763                } => {
764                    if metadata.is_some_and(|value| value.private_key_credential_ref.is_some()) {
765                        *private_key_encrypted = None;
766                    } else {
767                        hydrate_field(
768                            private_key,
769                            private_key_encrypted.as_deref(),
770                            &node.id,
771                            "private_key",
772                        );
773                    }
774                    if metadata.is_some_and(|value| value.passphrase_credential_ref.is_some()) {
775                        *passphrase_encrypted = None;
776                    } else {
777                        hydrate_field(
778                            passphrase,
779                            passphrase_encrypted.as_deref(),
780                            &node.id,
781                            "passphrase",
782                        );
783                    }
784                }
785            }
786        }
787    }
788
789    /// Re-encrypt SSH secrets from current in-memory plaintext before persisting.
790    ///
791    /// Mirrors [`Config::refresh_env_vars_encrypted`]: a non-empty plaintext is
792    /// (re-)encrypted; an empty plaintext leaves any existing ciphertext intact
793    /// (so a redacted round-trip where the client never re-sent the secret keeps
794    /// it). To CLEAR a secret, the caller swaps the whole `auth` variant.
795    pub fn refresh_cluster_fabric_encrypted(&mut self) -> Result<()> {
796        let credential_refs = self.cluster_fabric.credential_refs.clone();
797        for node in &mut self.cluster_fabric.nodes {
798            let node_id = node.id.clone();
799            let metadata = credential_refs.get(&node_id);
800            let NodePlacement::Ssh(target) = &mut node.placement else {
801                continue;
802            };
803            match &mut target.auth {
804                SshAuth::SystemSshConfig => {}
805                SshAuth::Password {
806                    password,
807                    password_encrypted,
808                } => {
809                    if metadata.is_some_and(|value| value.password_credential_ref.is_some()) {
810                        *password_encrypted = None;
811                    } else {
812                        refresh_field(password, password_encrypted, &node_id, "password")?;
813                    }
814                }
815                SshAuth::PrivateKey {
816                    private_key,
817                    private_key_encrypted,
818                    passphrase,
819                    passphrase_encrypted,
820                    ..
821                } => {
822                    if metadata.is_some_and(|value| value.private_key_credential_ref.is_some()) {
823                        *private_key_encrypted = None;
824                    } else {
825                        refresh_field(private_key, private_key_encrypted, &node_id, "private_key")?;
826                    }
827                    if metadata.is_some_and(|value| value.passphrase_credential_ref.is_some()) {
828                        *passphrase_encrypted = None;
829                    } else {
830                        refresh_field(passphrase, passphrase_encrypted, &node_id, "passphrase")?;
831                    }
832                }
833            }
834        }
835        Ok(())
836    }
837
838    /// Clear plaintext SSH secrets before serialization to disk.
839    pub fn sanitize_cluster_fabric_for_disk(&mut self) {
840        for node in &mut self.cluster_fabric.nodes {
841            let NodePlacement::Ssh(target) = &mut node.placement else {
842                continue;
843            };
844            match &mut target.auth {
845                SshAuth::SystemSshConfig => {}
846                SshAuth::Password { password, .. } => password.clear(),
847                SshAuth::PrivateKey {
848                    private_key,
849                    passphrase,
850                    ..
851                } => {
852                    private_key.clear();
853                    passphrase.clear();
854                }
855            }
856        }
857    }
858}
859
860/// Decrypt `encrypted` into `plaintext` when `plaintext` is currently empty.
861fn hydrate_field(plaintext: &mut String, encrypted: Option<&str>, node_id: &str, what: &str) {
862    if !plaintext.trim().is_empty() {
863        return;
864    }
865    let Some(encrypted) = encrypted else {
866        return;
867    };
868    match crate::encryption::decrypt(encrypted) {
869        Ok(value) => *plaintext = value,
870        Err(e) => tracing::warn!("Failed to decrypt node '{node_id}' {what}: {e}"),
871    }
872}
873
874/// (Re-)encrypt `plaintext` into `encrypted` when `plaintext` is non-empty.
875/// An empty plaintext is left untouched so a redacted update keeps the secret.
876fn refresh_field(
877    plaintext: &str,
878    encrypted: &mut Option<String>,
879    node_id: &str,
880    what: &str,
881) -> Result<()> {
882    if plaintext.trim().is_empty() {
883        return Ok(());
884    }
885    *encrypted = Some(
886        crate::encryption::encrypt(plaintext)
887            .with_context(|| format!("Failed to encrypt node '{node_id}' {what}"))?,
888    );
889    Ok(())
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895
896    fn ssh_node(id: &str, auth: SshAuth) -> Node {
897        Node {
898            id: id.to_string(),
899            label: id.to_string(),
900            placement: NodePlacement::Ssh(SshTarget {
901                host: "10.0.0.1".to_string(),
902                port: 22,
903                username: "deploy".to_string(),
904                auth,
905                host_key_fingerprint: None,
906            }),
907            trust_level: TrustLevel::Trusted,
908            deploy: DeployProfile::default(),
909            state: None,
910            enabled: true,
911        }
912    }
913
914    #[test]
915    fn empty_fabric_is_skipped_on_serialize() {
916        let cfg = ClusterFabricConfig::default();
917        assert!(cfg.is_empty());
918    }
919
920    #[test]
921    fn credential_metadata_round_trips_without_secret_material() {
922        let mut fabric = ClusterFabricConfig::default();
923        fabric.credential_refs.insert(
924            "node/with unsafe id".to_string(),
925            ClusterNodeCredentialRefs {
926                password_credential_ref: Some(
927                    cluster_password_credential_ref("node/with unsafe id").unwrap(),
928                ),
929                password_configured: true,
930                private_key_credential_ref: Some(
931                    cluster_private_key_credential_ref("node/with unsafe id").unwrap(),
932                ),
933                private_key_configured: true,
934                passphrase_credential_ref: Some(
935                    cluster_passphrase_credential_ref("node/with unsafe id").unwrap(),
936                ),
937                passphrase_configured: true,
938            },
939        );
940
941        let encoded = serde_json::to_string(&fabric).unwrap();
942        assert!(encoded.contains("password_credential_ref"));
943        assert!(encoded.contains("private_key_credential_ref"));
944        assert!(encoded.contains("passphrase_credential_ref"));
945        assert!(!encoded.contains("password_encrypted"));
946        assert!(!encoded.contains("private_key_encrypted"));
947        assert!(!encoded.contains("passphrase_encrypted"));
948        let decoded: ClusterFabricConfig = serde_json::from_str(&encoded).unwrap();
949        assert_eq!(decoded, fabric);
950    }
951
952    #[test]
953    fn canonical_cluster_refs_are_node_scoped_and_injective() {
954        let slash = cluster_password_credential_ref("node/a").unwrap();
955        let dot = cluster_password_credential_ref("node.a").unwrap();
956        assert_ne!(slash, dot);
957        assert!(slash.as_str().starts_with("cluster."));
958        assert!(slash.as_str().ends_with(".password"));
959        assert_ne!(
960            cluster_private_key_credential_ref("node/a").unwrap(),
961            cluster_passphrase_credential_ref("node/a").unwrap()
962        );
963    }
964
965    #[test]
966    fn pruning_nodes_drops_orphaned_credential_metadata() {
967        let mut fabric = ClusterFabricConfig::default();
968        fabric
969            .nodes
970            .push(ssh_node("kept", SshAuth::SystemSshConfig));
971        for id in ["kept", "deleted"] {
972            fabric.credential_refs.insert(
973                id.to_string(),
974                ClusterNodeCredentialRefs {
975                    password_credential_ref: Some(cluster_password_credential_ref(id).unwrap()),
976                    password_configured: true,
977                    ..ClusterNodeCredentialRefs::default()
978                },
979            );
980        }
981
982        fabric.prune_orphaned_credential_refs();
983        assert!(fabric.credential_refs.contains_key("kept"));
984        assert!(!fabric.credential_refs.contains_key("deleted"));
985    }
986
987    #[test]
988    fn password_secret_round_trips_through_encrypt_sanitize_hydrate() {
989        let mut config = Config::default();
990        config.cluster_fabric.nodes.push(ssh_node(
991            "n1",
992            SshAuth::Password {
993                password: "hunter2".to_string(),
994                password_encrypted: None,
995            },
996        ));
997
998        // Persist path: encrypt then sanitize (what save_to_dir does).
999        config.refresh_cluster_fabric_encrypted().unwrap();
1000        config.sanitize_cluster_fabric_for_disk();
1001
1002        // After sanitize: plaintext gone, ciphertext present.
1003        let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
1004            panic!("expected ssh");
1005        };
1006        let SshAuth::Password {
1007            password,
1008            password_encrypted,
1009        } = &t.auth
1010        else {
1011            panic!("expected password auth");
1012        };
1013        assert!(password.is_empty(), "plaintext must be cleared for disk");
1014        assert!(password_encrypted.is_some(), "ciphertext must be stored");
1015
1016        // Load path: hydrate restores plaintext.
1017        config.hydrate_cluster_fabric_from_encrypted();
1018        let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
1019            panic!("expected ssh");
1020        };
1021        let SshAuth::Password { password, .. } = &t.auth else {
1022            panic!("expected password auth");
1023        };
1024        assert_eq!(password, "hunter2", "plaintext restored on hydrate");
1025    }
1026
1027    #[test]
1028    fn private_key_and_passphrase_round_trip() {
1029        let mut config = Config::default();
1030        config.cluster_fabric.nodes.push(ssh_node(
1031            "n2",
1032            SshAuth::PrivateKey {
1033                private_key: "-----BEGIN KEY-----xyz".to_string(),
1034                private_key_encrypted: None,
1035                private_key_path: None,
1036                passphrase: "pp".to_string(),
1037                passphrase_encrypted: None,
1038            },
1039        ));
1040
1041        config.refresh_cluster_fabric_encrypted().unwrap();
1042        config.sanitize_cluster_fabric_for_disk();
1043        config.hydrate_cluster_fabric_from_encrypted();
1044
1045        let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
1046            panic!("expected ssh");
1047        };
1048        let SshAuth::PrivateKey {
1049            private_key,
1050            passphrase,
1051            ..
1052        } = &t.auth
1053        else {
1054            panic!("expected private key auth");
1055        };
1056        assert_eq!(private_key, "-----BEGIN KEY-----xyz");
1057        assert_eq!(passphrase, "pp");
1058    }
1059
1060    #[test]
1061    fn empty_plaintext_keeps_existing_ciphertext_on_refresh() {
1062        // Simulates a redacted update: the client returns an empty secret, and
1063        // we must NOT wipe the stored ciphertext.
1064        let mut config = Config::default();
1065        config.cluster_fabric.nodes.push(ssh_node(
1066            "n3",
1067            SshAuth::Password {
1068                password: "secret".to_string(),
1069                password_encrypted: None,
1070            },
1071        ));
1072        config.refresh_cluster_fabric_encrypted().unwrap();
1073        config.sanitize_cluster_fabric_for_disk();
1074
1075        // Now plaintext is empty (as if loaded but not re-hydrated); refresh again.
1076        config.refresh_cluster_fabric_encrypted().unwrap();
1077        config.hydrate_cluster_fabric_from_encrypted();
1078
1079        let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
1080            panic!("expected ssh");
1081        };
1082        let SshAuth::Password { password, .. } = &t.auth else {
1083            panic!("expected password auth");
1084        };
1085        assert_eq!(
1086            password, "secret",
1087            "ciphertext preserved across empty refresh"
1088        );
1089    }
1090
1091    #[test]
1092    fn local_node_has_no_secrets_to_touch() {
1093        let mut config = Config::default();
1094        config.cluster_fabric.nodes.push(Node {
1095            id: "local".to_string(),
1096            label: "local".to_string(),
1097            placement: NodePlacement::Local,
1098            trust_level: TrustLevel::Trusted,
1099            deploy: DeployProfile::default(),
1100            state: None,
1101            enabled: true,
1102        });
1103        // Should be a no-op, no panic.
1104        config.refresh_cluster_fabric_encrypted().unwrap();
1105        config.sanitize_cluster_fabric_for_disk();
1106        config.hydrate_cluster_fabric_from_encrypted();
1107        assert_eq!(config.cluster_fabric.nodes.len(), 1);
1108    }
1109
1110    #[test]
1111    fn isolated_cluster_credentials_hydrate_only_from_canonical_refs() {
1112        let _key = crate::encryption::set_test_encryption_key([0xb1; 32]);
1113        let dir = tempfile::tempdir().unwrap();
1114        let password_ref = cluster_password_credential_ref("password-node").unwrap();
1115        let key_ref = cluster_private_key_credential_ref("key-node").unwrap();
1116        let passphrase_ref = cluster_passphrase_credential_ref("key-node").unwrap();
1117        let store = crate::CredentialStore::open(dir.path());
1118        store
1119            .replace(
1120                password_ref.clone(),
1121                "password-secret",
1122                crate::CredentialSource::User,
1123                0,
1124            )
1125            .unwrap();
1126        store
1127            .replace(
1128                key_ref.clone(),
1129                "private-key-secret",
1130                crate::CredentialSource::User,
1131                1,
1132            )
1133            .unwrap();
1134        store
1135            .replace(
1136                passphrase_ref.clone(),
1137                "passphrase-secret",
1138                crate::CredentialSource::User,
1139                2,
1140            )
1141            .unwrap();
1142
1143        let mut config = Config::default();
1144        config.cluster_fabric.nodes.push(ssh_node(
1145            "password-node",
1146            SshAuth::Password {
1147                password: String::new(),
1148                password_encrypted: None,
1149            },
1150        ));
1151        config.cluster_fabric.nodes.push(ssh_node(
1152            "key-node",
1153            SshAuth::PrivateKey {
1154                private_key: String::new(),
1155                private_key_encrypted: None,
1156                private_key_path: None,
1157                passphrase: String::new(),
1158                passphrase_encrypted: None,
1159            },
1160        ));
1161        config.cluster_fabric.credential_refs.insert(
1162            "password-node".to_string(),
1163            ClusterNodeCredentialRefs {
1164                password_credential_ref: Some(password_ref),
1165                password_configured: true,
1166                ..ClusterNodeCredentialRefs::default()
1167            },
1168        );
1169        config.cluster_fabric.credential_refs.insert(
1170            "key-node".to_string(),
1171            ClusterNodeCredentialRefs {
1172                private_key_credential_ref: Some(key_ref),
1173                private_key_configured: true,
1174                passphrase_credential_ref: Some(passphrase_ref),
1175                passphrase_configured: true,
1176                ..ClusterNodeCredentialRefs::default()
1177            },
1178        );
1179
1180        config
1181            .hydrate_cluster_credentials_from_store(dir.path())
1182            .unwrap();
1183        let NodePlacement::Ssh(password_target) = &config.cluster_fabric.nodes[0].placement else {
1184            panic!("expected SSH node")
1185        };
1186        let SshAuth::Password { password, .. } = &password_target.auth else {
1187            panic!("expected password auth")
1188        };
1189        assert_eq!(password, "password-secret");
1190        let NodePlacement::Ssh(key_target) = &config.cluster_fabric.nodes[1].placement else {
1191            panic!("expected SSH node")
1192        };
1193        let SshAuth::PrivateKey {
1194            private_key,
1195            passphrase,
1196            ..
1197        } = &key_target.auth
1198        else {
1199            panic!("expected private-key auth")
1200        };
1201        assert_eq!(private_key, "private-key-secret");
1202        assert_eq!(passphrase, "passphrase-secret");
1203
1204        config
1205            .hydrate_cluster_credentials_from_store(dir.path())
1206            .unwrap();
1207        config.refresh_cluster_fabric_encrypted().unwrap();
1208        config.sanitize_cluster_fabric_for_disk();
1209        let durable = serde_json::to_string(&config).unwrap();
1210        for forbidden in [
1211            "password-secret",
1212            "private-key-secret",
1213            "passphrase-secret",
1214            "password_encrypted",
1215            "private_key_encrypted",
1216            "passphrase_encrypted",
1217        ] {
1218            assert!(!durable.contains(forbidden), "persisted {forbidden}");
1219        }
1220    }
1221
1222    #[test]
1223    fn recursive_config_debug_redacts_all_cluster_credential_forms() {
1224        let _key = crate::encryption::set_test_encryption_key([0xc7; 32]);
1225        let dir = tempfile::tempdir().unwrap();
1226        let password_ref = cluster_password_credential_ref("password-debug").unwrap();
1227        let key_ref = cluster_private_key_credential_ref("key-debug").unwrap();
1228        let passphrase_ref = cluster_passphrase_credential_ref("key-debug").unwrap();
1229        let store = crate::CredentialStore::open(dir.path());
1230        for (revision, (reference, value)) in [
1231            (&password_ref, "debug-password-plaintext"),
1232            (&key_ref, "debug-private-key-plaintext"),
1233            (&passphrase_ref, "debug-passphrase-plaintext"),
1234        ]
1235        .into_iter()
1236        .enumerate()
1237        {
1238            store
1239                .replace(
1240                    reference.clone(),
1241                    value,
1242                    crate::CredentialSource::User,
1243                    revision as u64,
1244                )
1245                .unwrap();
1246        }
1247
1248        let mut config = Config::default();
1249        config.cluster_fabric.nodes.push(ssh_node(
1250            "password-debug",
1251            SshAuth::Password {
1252                password: String::new(),
1253                password_encrypted: None,
1254            },
1255        ));
1256        config.cluster_fabric.nodes.push(ssh_node(
1257            "key-debug",
1258            SshAuth::PrivateKey {
1259                private_key: String::new(),
1260                private_key_encrypted: None,
1261                private_key_path: Some("/safe/on-host/key".to_string()),
1262                passphrase: String::new(),
1263                passphrase_encrypted: None,
1264            },
1265        ));
1266        config.cluster_fabric.credential_refs.insert(
1267            "password-debug".to_string(),
1268            ClusterNodeCredentialRefs {
1269                password_credential_ref: Some(password_ref.clone()),
1270                password_configured: true,
1271                ..ClusterNodeCredentialRefs::default()
1272            },
1273        );
1274        config.cluster_fabric.credential_refs.insert(
1275            "key-debug".to_string(),
1276            ClusterNodeCredentialRefs {
1277                private_key_credential_ref: Some(key_ref.clone()),
1278                private_key_configured: true,
1279                passphrase_credential_ref: Some(passphrase_ref.clone()),
1280                passphrase_configured: true,
1281                ..ClusterNodeCredentialRefs::default()
1282            },
1283        );
1284        config
1285            .hydrate_cluster_credentials_from_store(dir.path())
1286            .unwrap();
1287
1288        let NodePlacement::Ssh(password_target) = &mut config.cluster_fabric.nodes[0].placement
1289        else {
1290            panic!("expected password SSH node")
1291        };
1292        let SshAuth::Password {
1293            password_encrypted, ..
1294        } = &mut password_target.auth
1295        else {
1296            panic!("expected password auth")
1297        };
1298        *password_encrypted = Some("debug-password-ciphertext".to_string());
1299
1300        let NodePlacement::Ssh(key_target) = &mut config.cluster_fabric.nodes[1].placement else {
1301            panic!("expected private-key SSH node")
1302        };
1303        let SshAuth::PrivateKey {
1304            private_key_encrypted,
1305            passphrase_encrypted,
1306            ..
1307        } = &mut key_target.auth
1308        else {
1309            panic!("expected private-key auth")
1310        };
1311        *private_key_encrypted = Some("debug-private-key-ciphertext".to_string());
1312        *passphrase_encrypted = Some("********debug-mask-sentinel".to_string());
1313
1314        let recursive_debug = format!("{config:?}");
1315        let values_debug = format!("{:?}", config.section_values());
1316        for forbidden in [
1317            "debug-password-plaintext",
1318            "debug-private-key-plaintext",
1319            "debug-passphrase-plaintext",
1320            "debug-password-ciphertext",
1321            "debug-private-key-ciphertext",
1322            "********debug-mask-sentinel",
1323            password_ref.as_str(),
1324            key_ref.as_str(),
1325            passphrase_ref.as_str(),
1326        ] {
1327            assert!(
1328                !recursive_debug.contains(forbidden),
1329                "Config Debug leaked {forbidden}"
1330            );
1331            assert!(
1332                !values_debug.contains(forbidden),
1333                "ConfigValues Debug leaked {forbidden}"
1334            );
1335        }
1336        assert!(recursive_debug.contains("password_configured: true"));
1337        assert!(recursive_debug.contains("inline_private_key_configured: true"));
1338        assert!(recursive_debug.contains("passphrase_configured: true"));
1339        assert!(recursive_debug.contains("legacy_password_ciphertext_present: true"));
1340        assert!(recursive_debug.contains("legacy_private_key_ciphertext_present: true"));
1341        assert!(recursive_debug.contains("legacy_passphrase_ciphertext_present: true"));
1342    }
1343
1344    #[test]
1345    fn cluster_without_credential_refs_does_not_read_the_credential_document() {
1346        let dir = tempfile::tempdir().unwrap();
1347        std::fs::write(dir.path().join("credentials.json"), b"{invalid").unwrap();
1348        let mut config = Config::default();
1349        config
1350            .cluster_fabric
1351            .nodes
1352            .push(ssh_node("system-node", SshAuth::SystemSshConfig));
1353
1354        config
1355            .hydrate_cluster_credentials_from_store(dir.path())
1356            .unwrap();
1357    }
1358
1359    #[test]
1360    fn cluster_credential_snapshot_preserves_transaction_readiness_checks() {
1361        let dir = tempfile::tempdir().unwrap();
1362        std::fs::write(
1363            dir.path().join("config-credential-migration.json"),
1364            b"{invalid",
1365        )
1366        .unwrap();
1367        let reference = cluster_password_credential_ref("node").unwrap();
1368        let mut config = Config::default();
1369        config.cluster_fabric.nodes.push(ssh_node(
1370            "node",
1371            SshAuth::Password {
1372                password: String::new(),
1373                password_encrypted: None,
1374            },
1375        ));
1376        config.cluster_fabric.credential_refs.insert(
1377            "node".to_string(),
1378            ClusterNodeCredentialRefs {
1379                password_credential_ref: Some(reference),
1380                password_configured: true,
1381                ..ClusterNodeCredentialRefs::default()
1382            },
1383        );
1384
1385        let error = config
1386            .hydrate_cluster_credentials_from_store(dir.path())
1387            .unwrap_err();
1388        assert!(error.to_string().contains("migration is pending"));
1389    }
1390
1391    #[test]
1392    fn unavailable_or_shared_cluster_ref_fails_closed() {
1393        let dir = tempfile::tempdir().unwrap();
1394        let reference = cluster_password_credential_ref("node").unwrap();
1395        let mut config = Config::default();
1396        config.cluster_fabric.nodes.push(ssh_node(
1397            "node",
1398            SshAuth::Password {
1399                password: String::new(),
1400                password_encrypted: None,
1401            },
1402        ));
1403        config.cluster_fabric.credential_refs.insert(
1404            "node".to_string(),
1405            ClusterNodeCredentialRefs {
1406                password_credential_ref: Some(reference.clone()),
1407                password_configured: true,
1408                ..ClusterNodeCredentialRefs::default()
1409            },
1410        );
1411
1412        let error = config
1413            .hydrate_cluster_credentials_from_store(dir.path())
1414            .unwrap_err();
1415        assert!(error.to_string().contains("unavailable"));
1416
1417        config.notifications.ntfy.credential_ref = Some(reference);
1418        config.notifications.ntfy.configured = true;
1419        let error = config
1420            .hydrate_cluster_credentials_from_store(dir.path())
1421            .unwrap_err();
1422        assert!(error.to_string().contains("shared"));
1423    }
1424
1425    #[test]
1426    fn late_legacy_cluster_secret_is_rejected_and_cleared() {
1427        let dir = tempfile::tempdir().unwrap();
1428        let ciphertext = crate::encryption::encrypt("late-secret").unwrap();
1429        let mut config = Config::default();
1430        config.cluster_fabric.nodes.push(ssh_node(
1431            "node",
1432            SshAuth::Password {
1433                password: "late-plaintext".to_string(),
1434                password_encrypted: Some(ciphertext),
1435            },
1436        ));
1437
1438        let error = config
1439            .hydrate_cluster_credentials_from_store(dir.path())
1440            .unwrap_err();
1441        assert!(error.to_string().contains("appeared after migration"));
1442        let NodePlacement::Ssh(target) = &config.cluster_fabric.nodes[0].placement else {
1443            panic!("expected SSH node")
1444        };
1445        let SshAuth::Password {
1446            password,
1447            password_encrypted,
1448        } = &target.auth
1449        else {
1450            panic!("expected password auth")
1451        };
1452        assert!(password.is_empty());
1453        assert!(password_encrypted.is_none());
1454    }
1455}