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 anyhow::{Context, Result};
20use serde::{Deserialize, Serialize};
21
22use crate::config::Config;
23
24/// The persisted cluster fabric: clusters (groups) + nodes (machines).
25///
26/// Additive and back-compat: an absent `cluster_fabric` key deserializes to the
27/// empty default and never appears on disk (`skip_serializing_if`).
28#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
29pub struct ClusterFabricConfig {
30    /// Named groups of node ids (the disclosure/grouping unit).
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub clusters: Vec<Cluster>,
33    /// The registered machines.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub nodes: Vec<Node>,
36    /// Seconds between background health probes of Running/Unreachable nodes.
37    /// Omitted → the built-in default (30s); `0` disables the health monitor.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub health_interval_secs: Option<u64>,
40}
41
42/// Default health-probe cadence when `health_interval_secs` is unset.
43pub const DEFAULT_HEALTH_INTERVAL_SECS: u64 = 30;
44
45impl ClusterFabricConfig {
46    /// True when there are no clusters and no nodes (the serialize-skip gate).
47    pub fn is_empty(&self) -> bool {
48        self.clusters.is_empty() && self.nodes.is_empty()
49    }
50
51    /// Resolve the health-monitor cadence: `None` when disabled (`0`), else the
52    /// configured seconds or the [`DEFAULT_HEALTH_INTERVAL_SECS`] default.
53    pub fn health_interval(&self) -> Option<std::time::Duration> {
54        match self.health_interval_secs {
55            Some(0) => None,
56            Some(s) => Some(std::time::Duration::from_secs(s)),
57            None => Some(std::time::Duration::from_secs(DEFAULT_HEALTH_INTERVAL_SECS)),
58        }
59    }
60
61    /// Look up a node by id.
62    pub fn node(&self, id: &str) -> Option<&Node> {
63        self.nodes.iter().find(|n| n.id == id)
64    }
65
66    /// Mutable node lookup by id.
67    pub fn node_mut(&mut self, id: &str) -> Option<&mut Node> {
68        self.nodes.iter_mut().find(|n| n.id == id)
69    }
70
71    /// Look up a cluster by name.
72    pub fn cluster(&self, name: &str) -> Option<&Cluster> {
73        self.clusters.iter().find(|c| c.name == name)
74    }
75}
76
77/// A named group of node ids. Clusters carry no credentials — they are pure
78/// grouping for disclosure and operator organization.
79#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
80pub struct Cluster {
81    pub name: String,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub description: Option<String>,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub node_ids: Vec<String>,
86}
87
88/// One machine: where it is (`placement`), how much we trust it (`trust_level`),
89/// what to launch (`deploy`), and engine-owned live `state`.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct Node {
92    /// Stable unique id (uuid). The `ask_agent`/dispatch handle the agent uses.
93    pub id: String,
94    /// Human label, e.g. "gpu-1".
95    pub label: String,
96    /// Local (localhost, no SSH) or Ssh (remote) — the ONLY local/remote diff.
97    pub placement: NodePlacement,
98    /// Default `Trusted` (own infra ⇒ ship creds). `Untrusted` ⇒ proxy-home (future).
99    #[serde(default)]
100    pub trust_level: TrustLevel,
101    /// What to launch + which artifact to upload.
102    #[serde(default)]
103    pub deploy: DeployProfile,
104    /// Engine-owned live state (status/worker/health). `None` until first deploy.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub state: Option<NodeState>,
107    /// Operator on/off switch (a disabled node is hidden from dispatch).
108    #[serde(default = "default_true")]
109    pub enabled: bool,
110}
111
112fn default_true() -> bool {
113    true
114}
115
116/// Where a node lives. `remote = local + {ssh connect, upload, reverse tunnel}`.
117#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(tag = "type", rename_all = "snake_case")]
119pub enum NodePlacement {
120    /// localhost → `LocalProcessDeployer`; no ssh/upload/tunnel.
121    #[default]
122    Local,
123    /// remote → russh/system-ssh deployer + binary upload + reverse tunnel.
124    Ssh(SshTarget),
125}
126
127/// Trust posture for credential handling (RFC v2 §7).
128#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
129#[serde(rename_all = "snake_case")]
130pub enum TrustLevel {
131    /// Own infra: OK to sync provider/MCP creds to the node (default).
132    #[default]
133    Trusted,
134    /// Future: proxy LLM/MCP calls home so no secret leaves the orchestrator.
135    Untrusted,
136}
137
138/// SSH connection target for a remote node. Secrets live in `auth`.
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct SshTarget {
141    pub host: String,
142    #[serde(default = "default_ssh_port")]
143    pub port: u16,
144    pub username: String,
145    pub auth: SshAuth,
146    /// TOFU-pinned host key fingerprint; a changed key is rejected as MITM.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub host_key_fingerprint: Option<String>,
149}
150
151fn default_ssh_port() -> u16 {
152    22
153}
154
155/// How to authenticate the SSH connection. Secret material is encrypted at rest
156/// (the `*_encrypted` fields); plaintext is hydrated in memory and cleared
157/// before disk, exactly like [`crate::config::EnvVarEntry`].
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(tag = "method", rename_all = "snake_case")]
160pub enum SshAuth {
161    /// Use the bamboo host's own ssh agent/config (→ system-ssh deployer). No
162    /// stored secret — delegated to the host. This is the only auth method the
163    /// existing system-`ssh` `SshDeployer` can serve.
164    SystemSshConfig,
165    /// Stored password.
166    Password {
167        /// Plaintext — hydrated in memory, empty on disk.
168        #[serde(default)]
169        password: String,
170        /// Ciphertext on disk.
171        #[serde(default, skip_serializing_if = "Option::is_none")]
172        password_encrypted: Option<String>,
173    },
174    /// Private key — either inline PEM (secret, encrypted) or an on-host path
175    /// (not a secret). An optional passphrase is always a secret.
176    PrivateKey {
177        /// Inline PEM plaintext — hydrated in memory, empty on disk.
178        #[serde(default)]
179        private_key: String,
180        /// Ciphertext of the inline PEM on disk.
181        #[serde(default, skip_serializing_if = "Option::is_none")]
182        private_key_encrypted: Option<String>,
183        /// Path to a key file on the bamboo host (NOT a secret).
184        #[serde(default, skip_serializing_if = "Option::is_none")]
185        private_key_path: Option<String>,
186        /// Optional key passphrase plaintext — hydrated in memory, empty on disk.
187        #[serde(default)]
188        passphrase: String,
189        /// Ciphertext of the passphrase on disk.
190        #[serde(default, skip_serializing_if = "Option::is_none")]
191        passphrase_encrypted: Option<String>,
192    },
193}
194
195/// What to launch on the node + which artifact to upload (RFC v2 §6).
196#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
197pub struct DeployProfile {
198    /// Local-on-bamboo-host binary path to SFTP-upload (correct remote arch).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub artifact_path: Option<String>,
201    /// Expected sha256 of the artifact (idempotent redeploy / integrity).
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub artifact_sha256: Option<String>,
204    /// Remote install dir (default `~/.bamboo-deploy`).
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub remote_dir: Option<String>,
207    /// Role the broker-agent runs as.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub default_role: Option<String>,
210    /// Model override for the deployed worker.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub model: Option<String>,
213    /// Workspace override for the deployed worker.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub workspace: Option<String>,
216    /// Auto-redeploy this node when the health monitor finds its worker gone
217    /// (opt-in; only recovers a node that WAS Running, never a user-Stopped one).
218    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
219    pub auto_recover: bool,
220}
221
222/// Engine-owned live state. Written by the deploy engine (P2), not the operator.
223#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
224pub struct NodeState {
225    pub status: NodeStatus,
226    /// Broker mailbox id (the `ask_agent` target) once deployed.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub worker_id: Option<String>,
229    /// Name of the encrypted env var holding this node's broker token.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub token_env: Option<String>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub remote_pid: Option<u32>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub log_path: Option<String>,
236    /// RFC3339 timestamps + last error, all optional.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub deployed_at: Option<String>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub last_health: Option<String>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub last_error: Option<String>,
243}
244
245/// Lifecycle status of a node's worker.
246#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
247#[serde(rename_all = "snake_case")]
248pub enum NodeStatus {
249    #[default]
250    NotDeployed,
251    Deploying,
252    Running,
253    Unreachable,
254    Stopped,
255    Failed,
256}
257
258// ── Crypto: mirror the env-vars AES-256-GCM at-rest pattern ────────────────
259
260impl Config {
261    /// Decrypt SSH secrets into in-memory plaintext after loading config.
262    ///
263    /// Mirrors [`Config::hydrate_env_vars_from_encrypted`]: only fills a
264    /// plaintext field that is currently empty, from its `*_encrypted`
265    /// counterpart.
266    pub fn hydrate_cluster_fabric_from_encrypted(&mut self) {
267        for node in &mut self.cluster_fabric.nodes {
268            let NodePlacement::Ssh(target) = &mut node.placement else {
269                continue;
270            };
271            match &mut target.auth {
272                SshAuth::SystemSshConfig => {}
273                SshAuth::Password {
274                    password,
275                    password_encrypted,
276                } => {
277                    hydrate_field(
278                        password,
279                        password_encrypted.as_deref(),
280                        &node.id,
281                        "password",
282                    );
283                }
284                SshAuth::PrivateKey {
285                    private_key,
286                    private_key_encrypted,
287                    passphrase,
288                    passphrase_encrypted,
289                    ..
290                } => {
291                    hydrate_field(
292                        private_key,
293                        private_key_encrypted.as_deref(),
294                        &node.id,
295                        "private_key",
296                    );
297                    hydrate_field(
298                        passphrase,
299                        passphrase_encrypted.as_deref(),
300                        &node.id,
301                        "passphrase",
302                    );
303                }
304            }
305        }
306    }
307
308    /// Re-encrypt SSH secrets from current in-memory plaintext before persisting.
309    ///
310    /// Mirrors [`Config::refresh_env_vars_encrypted`]: a non-empty plaintext is
311    /// (re-)encrypted; an empty plaintext leaves any existing ciphertext intact
312    /// (so a redacted round-trip where the client never re-sent the secret keeps
313    /// it). To CLEAR a secret, the caller swaps the whole `auth` variant.
314    pub fn refresh_cluster_fabric_encrypted(&mut self) -> Result<()> {
315        for node in &mut self.cluster_fabric.nodes {
316            let node_id = node.id.clone();
317            let NodePlacement::Ssh(target) = &mut node.placement else {
318                continue;
319            };
320            match &mut target.auth {
321                SshAuth::SystemSshConfig => {}
322                SshAuth::Password {
323                    password,
324                    password_encrypted,
325                } => {
326                    refresh_field(password, password_encrypted, &node_id, "password")?;
327                }
328                SshAuth::PrivateKey {
329                    private_key,
330                    private_key_encrypted,
331                    passphrase,
332                    passphrase_encrypted,
333                    ..
334                } => {
335                    refresh_field(private_key, private_key_encrypted, &node_id, "private_key")?;
336                    refresh_field(passphrase, passphrase_encrypted, &node_id, "passphrase")?;
337                }
338            }
339        }
340        Ok(())
341    }
342
343    /// Clear plaintext SSH secrets before serialization to disk.
344    pub fn sanitize_cluster_fabric_for_disk(&mut self) {
345        for node in &mut self.cluster_fabric.nodes {
346            let NodePlacement::Ssh(target) = &mut node.placement else {
347                continue;
348            };
349            match &mut target.auth {
350                SshAuth::SystemSshConfig => {}
351                SshAuth::Password { password, .. } => password.clear(),
352                SshAuth::PrivateKey {
353                    private_key,
354                    passphrase,
355                    ..
356                } => {
357                    private_key.clear();
358                    passphrase.clear();
359                }
360            }
361        }
362    }
363}
364
365/// Decrypt `encrypted` into `plaintext` when `plaintext` is currently empty.
366fn hydrate_field(plaintext: &mut String, encrypted: Option<&str>, node_id: &str, what: &str) {
367    if !plaintext.trim().is_empty() {
368        return;
369    }
370    let Some(encrypted) = encrypted else {
371        return;
372    };
373    match crate::encryption::decrypt(encrypted) {
374        Ok(value) => *plaintext = value,
375        Err(e) => tracing::warn!("Failed to decrypt node '{node_id}' {what}: {e}"),
376    }
377}
378
379/// (Re-)encrypt `plaintext` into `encrypted` when `plaintext` is non-empty.
380/// An empty plaintext is left untouched so a redacted update keeps the secret.
381fn refresh_field(
382    plaintext: &str,
383    encrypted: &mut Option<String>,
384    node_id: &str,
385    what: &str,
386) -> Result<()> {
387    if plaintext.trim().is_empty() {
388        return Ok(());
389    }
390    *encrypted = Some(
391        crate::encryption::encrypt(plaintext)
392            .with_context(|| format!("Failed to encrypt node '{node_id}' {what}"))?,
393    );
394    Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn ssh_node(id: &str, auth: SshAuth) -> Node {
402        Node {
403            id: id.to_string(),
404            label: id.to_string(),
405            placement: NodePlacement::Ssh(SshTarget {
406                host: "10.0.0.1".to_string(),
407                port: 22,
408                username: "deploy".to_string(),
409                auth,
410                host_key_fingerprint: None,
411            }),
412            trust_level: TrustLevel::Trusted,
413            deploy: DeployProfile::default(),
414            state: None,
415            enabled: true,
416        }
417    }
418
419    #[test]
420    fn empty_fabric_is_skipped_on_serialize() {
421        let cfg = ClusterFabricConfig::default();
422        assert!(cfg.is_empty());
423    }
424
425    #[test]
426    fn password_secret_round_trips_through_encrypt_sanitize_hydrate() {
427        let mut config = Config::default();
428        config.cluster_fabric.nodes.push(ssh_node(
429            "n1",
430            SshAuth::Password {
431                password: "hunter2".to_string(),
432                password_encrypted: None,
433            },
434        ));
435
436        // Persist path: encrypt then sanitize (what save_to_dir does).
437        config.refresh_cluster_fabric_encrypted().unwrap();
438        config.sanitize_cluster_fabric_for_disk();
439
440        // After sanitize: plaintext gone, ciphertext present.
441        let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
442            panic!("expected ssh");
443        };
444        let SshAuth::Password {
445            password,
446            password_encrypted,
447        } = &t.auth
448        else {
449            panic!("expected password auth");
450        };
451        assert!(password.is_empty(), "plaintext must be cleared for disk");
452        assert!(password_encrypted.is_some(), "ciphertext must be stored");
453
454        // Load path: hydrate restores plaintext.
455        config.hydrate_cluster_fabric_from_encrypted();
456        let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
457            panic!("expected ssh");
458        };
459        let SshAuth::Password { password, .. } = &t.auth else {
460            panic!("expected password auth");
461        };
462        assert_eq!(password, "hunter2", "plaintext restored on hydrate");
463    }
464
465    #[test]
466    fn private_key_and_passphrase_round_trip() {
467        let mut config = Config::default();
468        config.cluster_fabric.nodes.push(ssh_node(
469            "n2",
470            SshAuth::PrivateKey {
471                private_key: "-----BEGIN KEY-----xyz".to_string(),
472                private_key_encrypted: None,
473                private_key_path: None,
474                passphrase: "pp".to_string(),
475                passphrase_encrypted: None,
476            },
477        ));
478
479        config.refresh_cluster_fabric_encrypted().unwrap();
480        config.sanitize_cluster_fabric_for_disk();
481        config.hydrate_cluster_fabric_from_encrypted();
482
483        let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
484            panic!("expected ssh");
485        };
486        let SshAuth::PrivateKey {
487            private_key,
488            passphrase,
489            ..
490        } = &t.auth
491        else {
492            panic!("expected private key auth");
493        };
494        assert_eq!(private_key, "-----BEGIN KEY-----xyz");
495        assert_eq!(passphrase, "pp");
496    }
497
498    #[test]
499    fn empty_plaintext_keeps_existing_ciphertext_on_refresh() {
500        // Simulates a redacted update: the client returns an empty secret, and
501        // we must NOT wipe the stored ciphertext.
502        let mut config = Config::default();
503        config.cluster_fabric.nodes.push(ssh_node(
504            "n3",
505            SshAuth::Password {
506                password: "secret".to_string(),
507                password_encrypted: None,
508            },
509        ));
510        config.refresh_cluster_fabric_encrypted().unwrap();
511        config.sanitize_cluster_fabric_for_disk();
512
513        // Now plaintext is empty (as if loaded but not re-hydrated); refresh again.
514        config.refresh_cluster_fabric_encrypted().unwrap();
515        config.hydrate_cluster_fabric_from_encrypted();
516
517        let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
518            panic!("expected ssh");
519        };
520        let SshAuth::Password { password, .. } = &t.auth else {
521            panic!("expected password auth");
522        };
523        assert_eq!(
524            password, "secret",
525            "ciphertext preserved across empty refresh"
526        );
527    }
528
529    #[test]
530    fn local_node_has_no_secrets_to_touch() {
531        let mut config = Config::default();
532        config.cluster_fabric.nodes.push(Node {
533            id: "local".to_string(),
534            label: "local".to_string(),
535            placement: NodePlacement::Local,
536            trust_level: TrustLevel::Trusted,
537            deploy: DeployProfile::default(),
538            state: None,
539            enabled: true,
540        });
541        // Should be a no-op, no panic.
542        config.refresh_cluster_fabric_encrypted().unwrap();
543        config.sanitize_cluster_fabric_for_disk();
544        config.hydrate_cluster_fabric_from_encrypted();
545        assert_eq!(config.cluster_fabric.nodes.len(), 1);
546    }
547}