Skip to main content

bamboo_server/handlers/settings/cluster_fabric/
mod.rs

1//! Remote Cluster Fabric operator API (RFC v2 §4): CRUD for nodes & clusters,
2//! plus lifecycle endpoints (test/deploy/stop/status/logs).
3//!
4//! P1 scope: the persisted registry + redacted round-trip. The lifecycle
5//! actions (`test`/`deploy`/`stop`/`logs`) are **stubbed `501 Not Implemented`**
6//! until the deploy engine lands in P2; `status` returns the persisted state
7//! (no live SSH probe yet).
8//!
9//! Secrets (SSH password / private key / passphrase) never leave the backend:
10//! responses are redacted ([`redact_node_value`]) and updates that re-send the
11//! mask sentinel preserve the stored ciphertext.
12
13use actix_web::{web, HttpResponse};
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16use uuid::Uuid;
17
18use bamboo_config::cluster_fabric::{
19    Cluster, DeployProfile, Node, NodePlacement, SshAuth, TrustLevel,
20};
21
22use crate::app_state::{AppState, ConfigUpdateEffects};
23use crate::error::AppError;
24
25mod deploy;
26
27/// The sentinel a redacted secret is replaced with (matches the rest of the
28/// settings redaction). An update that re-sends this value means "keep current".
29const SECRET_MASK: &str = "****...****";
30
31// ─── Request / response types ──────────────────────────────────────────
32
33/// Combined inventory returned by `GET /nodes` (nodes are redacted).
34#[derive(Serialize)]
35pub struct FabricListResponse {
36    pub nodes: Vec<Value>,
37    pub clusters: Vec<Cluster>,
38}
39
40/// Create/replace payload for a node. `id`/`state` are server-owned and ignored.
41#[derive(Debug, Clone, Deserialize)]
42pub struct NodeUpsertRequest {
43    pub label: String,
44    pub placement: NodePlacement,
45    #[serde(default)]
46    pub trust_level: TrustLevel,
47    #[serde(default)]
48    pub deploy: DeployProfile,
49    #[serde(default = "default_true")]
50    pub enabled: bool,
51}
52
53fn default_true() -> bool {
54    true
55}
56
57/// Create/replace payload for a cluster.
58#[derive(Debug, Clone, Deserialize)]
59pub struct ClusterUpsertRequest {
60    pub name: String,
61    #[serde(default)]
62    pub description: Option<String>,
63    #[serde(default)]
64    pub node_ids: Vec<String>,
65}
66
67// ─── Validation ────────────────────────────────────────────────────────
68
69fn validate_node(req: &NodeUpsertRequest) -> Result<(), AppError> {
70    if req.label.trim().is_empty() {
71        return Err(AppError::BadRequest("Node label is required".into()));
72    }
73    if let NodePlacement::Ssh(target) = &req.placement {
74        if target.host.trim().is_empty() {
75            return Err(AppError::BadRequest("SSH host is required".into()));
76        }
77        if target.username.trim().is_empty() {
78            return Err(AppError::BadRequest("SSH username is required".into()));
79        }
80        if target.port == 0 {
81            return Err(AppError::BadRequest("SSH port must be non-zero".into()));
82        }
83    }
84    Ok(())
85}
86
87// ─── Secret redaction & preservation ───────────────────────────────────
88
89/// Serialize a node to JSON with all SSH secret material masked / stripped.
90fn redact_node_value(node: &Node) -> Value {
91    let mut value = serde_json::to_value(node).unwrap_or(Value::Null);
92    if let Some(auth) = value
93        .get_mut("placement")
94        .and_then(|p| p.get_mut("auth"))
95        .and_then(|a| a.as_object_mut())
96    {
97        for field in ["password", "private_key", "passphrase"] {
98            if auth.get(field).and_then(|v| v.as_str()).is_some() {
99                auth.insert(field.to_string(), Value::String(SECRET_MASK.to_string()));
100            }
101            // Never expose ciphertext over the API.
102            auth.remove(&format!("{field}_encrypted"));
103        }
104    }
105    value
106}
107
108/// Carry forward the existing secret when an update re-sends the mask sentinel
109/// (or an empty secret) on the SAME auth variant. Changing the auth method
110/// discards the old secret (a fresh one is required).
111///
112/// The `existing` node here is the in-memory config node, whose secrets are
113/// hydrated to PLAINTEXT (its `*_encrypted` are `None` — ciphertext is only
114/// materialized on the disk-bound clone). So the source of truth to carry is the
115/// old plaintext; `refresh_cluster_fabric_encrypted` re-encrypts it on save.
116fn preserve_node_secrets(existing: &Node, incoming: &mut Node) {
117    let (NodePlacement::Ssh(old), NodePlacement::Ssh(new)) =
118        (&existing.placement, &mut incoming.placement)
119    else {
120        return;
121    };
122    match (&old.auth, &mut new.auth) {
123        (
124            SshAuth::Password {
125                password: old_pw,
126                password_encrypted: old_enc,
127            },
128            SshAuth::Password {
129                password,
130                password_encrypted,
131            },
132        ) => {
133            preserve_secret(password, password_encrypted, old_pw, old_enc);
134        }
135        (
136            SshAuth::PrivateKey {
137                private_key: old_pk,
138                private_key_encrypted: old_pk_enc,
139                passphrase: old_pp,
140                passphrase_encrypted: old_pp_enc,
141                ..
142            },
143            SshAuth::PrivateKey {
144                private_key,
145                private_key_encrypted,
146                passphrase,
147                passphrase_encrypted,
148                ..
149            },
150        ) => {
151            preserve_secret(private_key, private_key_encrypted, old_pk, old_pk_enc);
152            preserve_secret(passphrase, passphrase_encrypted, old_pp, old_pp_enc);
153        }
154        _ => {}
155    }
156}
157
158/// If `plaintext` is empty or the mask sentinel, replace it with the existing
159/// secret so it survives a redacted round-trip: prefer the old plaintext (what
160/// the hydrated in-memory config holds), else the old ciphertext.
161fn preserve_secret(
162    plaintext: &mut String,
163    encrypted: &mut Option<String>,
164    old_plaintext: &str,
165    old_encrypted: &Option<String>,
166) {
167    let keep = plaintext.trim().is_empty() || plaintext == SECRET_MASK;
168    if !keep {
169        return;
170    }
171    plaintext.clear();
172    if !old_plaintext.trim().is_empty() {
173        // Hydrated in-memory case: carry plaintext; refresh re-encrypts on save.
174        *plaintext = old_plaintext.to_string();
175    } else if encrypted.is_none() {
176        // Loaded-but-unhydrated case: carry the stored ciphertext as-is.
177        *encrypted = old_encrypted.clone();
178    }
179}
180
181// ─── Node handlers ─────────────────────────────────────────────────────
182
183/// `GET /v1/bamboo/settings/nodes` — list nodes (redacted) + clusters.
184pub async fn list_nodes(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
185    let config = app_state.config.read().await;
186    let nodes = config
187        .cluster_fabric
188        .nodes
189        .iter()
190        .map(redact_node_value)
191        .collect();
192    Ok(HttpResponse::Ok().json(FabricListResponse {
193        nodes,
194        clusters: config.cluster_fabric.clusters.clone(),
195    }))
196}
197
198/// `GET /v1/bamboo/settings/nodes/{id}` — one node (redacted).
199pub async fn get_node(
200    app_state: web::Data<AppState>,
201    path: web::Path<String>,
202) -> Result<HttpResponse, AppError> {
203    let id = path.into_inner();
204    let config = app_state.config.read().await;
205    let node = config
206        .cluster_fabric
207        .node(&id)
208        .ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
209    Ok(HttpResponse::Ok().json(redact_node_value(node)))
210}
211
212/// `POST /v1/bamboo/settings/nodes` — create a node.
213pub async fn create_node(
214    app_state: web::Data<AppState>,
215    payload: web::Json<NodeUpsertRequest>,
216) -> Result<HttpResponse, AppError> {
217    let req = payload.into_inner();
218    validate_node(&req)?;
219
220    let node = Node {
221        id: Uuid::new_v4().to_string(),
222        label: req.label,
223        placement: req.placement,
224        trust_level: req.trust_level,
225        deploy: req.deploy,
226        state: None,
227        enabled: req.enabled,
228    };
229    let node_id = node.id.clone();
230
231    let updated = app_state
232        .update_config(
233            move |cfg| {
234                cfg.cluster_fabric.nodes.push(node.clone());
235                Ok(())
236            },
237            ConfigUpdateEffects::default(),
238        )
239        .await?;
240
241    let created = updated
242        .cluster_fabric
243        .node(&node_id)
244        .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("created node missing")))?;
245    Ok(HttpResponse::Created().json(redact_node_value(created)))
246}
247
248/// `PUT /v1/bamboo/settings/nodes/{id}` — update a node (secret-preserving).
249pub async fn update_node(
250    app_state: web::Data<AppState>,
251    path: web::Path<String>,
252    payload: web::Json<NodeUpsertRequest>,
253) -> Result<HttpResponse, AppError> {
254    let id = path.into_inner();
255    let req = payload.into_inner();
256    validate_node(&req)?;
257    let id_for_response = id.clone();
258
259    let updated = app_state
260        .update_config(
261            move |cfg| {
262                let existing = cfg
263                    .cluster_fabric
264                    .node(&id)
265                    .cloned()
266                    .ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
267
268                let mut node = Node {
269                    id: existing.id.clone(),
270                    label: req.label.clone(),
271                    placement: req.placement.clone(),
272                    trust_level: req.trust_level,
273                    deploy: req.deploy.clone(),
274                    state: existing.state.clone(), // engine-owned: preserve
275                    enabled: req.enabled,
276                };
277                preserve_node_secrets(&existing, &mut node);
278
279                let slot = cfg
280                    .cluster_fabric
281                    .node_mut(&id)
282                    .expect("node existed above");
283                *slot = node;
284                Ok(())
285            },
286            ConfigUpdateEffects::default(),
287        )
288        .await?;
289
290    let node = updated
291        .cluster_fabric
292        .node(&id_for_response)
293        .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("updated node missing")))?;
294    Ok(HttpResponse::Ok().json(redact_node_value(node)))
295}
296
297/// `DELETE /v1/bamboo/settings/nodes/{id}` — remove a node.
298pub async fn delete_node(
299    app_state: web::Data<AppState>,
300    path: web::Path<String>,
301) -> Result<HttpResponse, AppError> {
302    let id = path.into_inner();
303
304    app_state
305        .update_config(
306            move |cfg| {
307                let before = cfg.cluster_fabric.nodes.len();
308                cfg.cluster_fabric.nodes.retain(|n| n.id != id);
309                if cfg.cluster_fabric.nodes.len() == before {
310                    return Err(AppError::NotFound(format!("Node '{id}'")));
311                }
312                // Drop the node from any cluster membership too.
313                for cluster in &mut cfg.cluster_fabric.clusters {
314                    cluster.node_ids.retain(|nid| nid != &id);
315                }
316                Ok(())
317            },
318            ConfigUpdateEffects::default(),
319        )
320        .await?;
321
322    Ok(HttpResponse::Ok().json(json!({ "success": true })))
323}
324
325// ─── Cluster handlers ──────────────────────────────────────────────────
326
327/// `POST /v1/bamboo/settings/clusters` — create a cluster.
328pub async fn create_cluster(
329    app_state: web::Data<AppState>,
330    payload: web::Json<ClusterUpsertRequest>,
331) -> Result<HttpResponse, AppError> {
332    let req = payload.into_inner();
333    if req.name.trim().is_empty() {
334        return Err(AppError::BadRequest("Cluster name is required".into()));
335    }
336
337    let updated = app_state
338        .update_config(
339            move |cfg| {
340                if cfg.cluster_fabric.cluster(&req.name).is_some() {
341                    return Err(AppError::BadRequest(format!(
342                        "Cluster '{}' already exists",
343                        req.name
344                    )));
345                }
346                cfg.cluster_fabric.clusters.push(Cluster {
347                    name: req.name.clone(),
348                    description: req.description.clone(),
349                    node_ids: req.node_ids.clone(),
350                });
351                Ok(())
352            },
353            ConfigUpdateEffects::default(),
354        )
355        .await?;
356
357    Ok(HttpResponse::Created().json(json!({ "clusters": updated.cluster_fabric.clusters })))
358}
359
360/// `PUT /v1/bamboo/settings/clusters/{name}` — update a cluster.
361pub async fn update_cluster(
362    app_state: web::Data<AppState>,
363    path: web::Path<String>,
364    payload: web::Json<ClusterUpsertRequest>,
365) -> Result<HttpResponse, AppError> {
366    let name = path.into_inner();
367    let req = payload.into_inner();
368
369    let updated = app_state
370        .update_config(
371            move |cfg| {
372                let cluster = cfg
373                    .cluster_fabric
374                    .clusters
375                    .iter_mut()
376                    .find(|c| c.name == name)
377                    .ok_or_else(|| AppError::NotFound(format!("Cluster '{name}'")))?;
378                cluster.description = req.description.clone();
379                cluster.node_ids = req.node_ids.clone();
380                // Allow rename via the body.
381                if !req.name.trim().is_empty() {
382                    cluster.name = req.name.clone();
383                }
384                Ok(())
385            },
386            ConfigUpdateEffects::default(),
387        )
388        .await?;
389
390    Ok(HttpResponse::Ok().json(json!({ "clusters": updated.cluster_fabric.clusters })))
391}
392
393/// `DELETE /v1/bamboo/settings/clusters/{name}` — remove a cluster (nodes kept).
394pub async fn delete_cluster(
395    app_state: web::Data<AppState>,
396    path: web::Path<String>,
397) -> Result<HttpResponse, AppError> {
398    let name = path.into_inner();
399
400    let updated = app_state
401        .update_config(
402            move |cfg| {
403                let before = cfg.cluster_fabric.clusters.len();
404                cfg.cluster_fabric.clusters.retain(|c| c.name != name);
405                if cfg.cluster_fabric.clusters.len() == before {
406                    return Err(AppError::NotFound(format!("Cluster '{name}'")));
407                }
408                Ok(())
409            },
410            ConfigUpdateEffects::default(),
411        )
412        .await?;
413
414    Ok(HttpResponse::Ok().json(json!({ "clusters": updated.cluster_fabric.clusters })))
415}
416
417// ─── Lifecycle ─────────────────────────────────────────────────────────
418
419/// `GET /v1/bamboo/settings/nodes/{id}/status` — persisted state (no live probe yet).
420pub async fn node_status(
421    app_state: web::Data<AppState>,
422    path: web::Path<String>,
423) -> Result<HttpResponse, AppError> {
424    let id = path.into_inner();
425    let config = app_state.config.read().await;
426    let node = config
427        .cluster_fabric
428        .node(&id)
429        .ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
430    Ok(HttpResponse::Ok().json(json!({
431        "id": node.id,
432        "enabled": node.enabled,
433        "state": node.state,
434    })))
435}
436
437/// Query params for `node_deploy`.
438#[derive(Debug, Clone, Deserialize)]
439pub struct DeployQuery {
440    /// Deploy the no-LLM echo executor (connectivity smoke test).
441    #[serde(default)]
442    pub echo: bool,
443}
444
445/// `POST /v1/bamboo/settings/nodes/{id}/deploy` — deploy a worker for the node.
446pub async fn node_deploy(
447    app_state: web::Data<AppState>,
448    path: web::Path<String>,
449    query: web::Query<DeployQuery>,
450) -> Result<HttpResponse, AppError> {
451    let id = path.into_inner();
452    let state = deploy::deploy_node(&app_state, &id, query.echo).await?;
453    Ok(HttpResponse::Ok().json(json!({ "id": id, "state": state })))
454}
455
456/// `POST /v1/bamboo/settings/nodes/{id}/stop` — stop the node's worker.
457pub async fn node_stop(
458    app_state: web::Data<AppState>,
459    path: web::Path<String>,
460) -> Result<HttpResponse, AppError> {
461    let id = path.into_inner();
462    let state = deploy::stop_node(&app_state, &id).await?;
463    Ok(HttpResponse::Ok().json(json!({ "id": id, "state": state })))
464}
465
466/// `POST /v1/bamboo/settings/nodes/{id}/test` — connectivity preflight (no deploy).
467pub async fn node_test(
468    app_state: web::Data<AppState>,
469    path: web::Path<String>,
470) -> Result<HttpResponse, AppError> {
471    let id = path.into_inner();
472    let info = deploy::test_node(&app_state, &id).await?;
473    Ok(HttpResponse::Ok().json(json!({ "id": id, "ok": true, "preflight": info })))
474}
475
476/// Query params for `node_logs`.
477#[derive(Debug, Clone, Deserialize)]
478pub struct LogsQuery {
479    /// Number of trailing lines to return (default 200).
480    #[serde(default = "default_log_lines")]
481    pub lines: usize,
482}
483
484fn default_log_lines() -> usize {
485    200
486}
487
488/// `GET /v1/bamboo/settings/nodes/{id}/logs` — tail the node worker's log.
489pub async fn node_logs(
490    app_state: web::Data<AppState>,
491    path: web::Path<String>,
492    query: web::Query<LogsQuery>,
493) -> Result<HttpResponse, AppError> {
494    let id = path.into_inner();
495    let lines = query.lines.clamp(1, 5000);
496    let logs = deploy::read_logs(&app_state, &id, lines).await?;
497    Ok(HttpResponse::Ok().json(json!({ "id": id, "logs": logs })))
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use bamboo_config::cluster_fabric::SshTarget;
504
505    fn pw_node(password: &str, encrypted: Option<&str>) -> Node {
506        Node {
507            id: "n1".into(),
508            label: "n1".into(),
509            placement: NodePlacement::Ssh(SshTarget {
510                host: "h".into(),
511                port: 22,
512                username: "u".into(),
513                auth: SshAuth::Password {
514                    password: password.into(),
515                    password_encrypted: encrypted.map(|s| s.into()),
516                },
517                host_key_fingerprint: None,
518            }),
519            trust_level: TrustLevel::Trusted,
520            deploy: DeployProfile::default(),
521            state: None,
522            enabled: true,
523        }
524    }
525
526    #[test]
527    fn redaction_masks_password_and_strips_ciphertext() {
528        let node = pw_node("hunter2", Some("ciphertext"));
529        let v = redact_node_value(&node);
530        let auth = &v["placement"]["auth"];
531        assert_eq!(auth["password"], SECRET_MASK);
532        assert!(auth.get("password_encrypted").is_none());
533    }
534
535    #[test]
536    fn redaction_omits_password_when_unset() {
537        // SystemSshConfig has no secret fields → nothing to mask.
538        let mut node = pw_node("", None);
539        node.placement = NodePlacement::Ssh(SshTarget {
540            host: "h".into(),
541            port: 22,
542            username: "u".into(),
543            auth: SshAuth::SystemSshConfig,
544            host_key_fingerprint: None,
545        });
546        let v = redact_node_value(&node);
547        assert_eq!(v["placement"]["auth"]["method"], "system_ssh_config");
548    }
549
550    #[test]
551    fn update_with_mask_preserves_existing_ciphertext() {
552        let existing = pw_node("", Some("stored-cipher"));
553        let mut incoming = pw_node(SECRET_MASK, None);
554        preserve_node_secrets(&existing, &mut incoming);
555        let NodePlacement::Ssh(t) = &incoming.placement else {
556            panic!()
557        };
558        let SshAuth::Password {
559            password,
560            password_encrypted,
561        } = &t.auth
562        else {
563            panic!()
564        };
565        assert!(password.is_empty(), "masked plaintext cleared");
566        assert_eq!(
567            password_encrypted.as_deref(),
568            Some("stored-cipher"),
569            "old ciphertext carried forward"
570        );
571    }
572
573    #[test]
574    fn update_with_mask_carries_hydrated_plaintext() {
575        // The realistic case: the in-memory existing node holds PLAINTEXT (it
576        // was hydrated on load); its ciphertext is None. A masked update must
577        // carry the plaintext forward so refresh can re-encrypt it on save.
578        let existing = pw_node("s3cr3t", None);
579        let mut incoming = pw_node(SECRET_MASK, None);
580        preserve_node_secrets(&existing, &mut incoming);
581        let NodePlacement::Ssh(t) = &incoming.placement else {
582            panic!()
583        };
584        let SshAuth::Password { password, .. } = &t.auth else {
585            panic!()
586        };
587        assert_eq!(password, "s3cr3t", "hydrated plaintext carried forward");
588    }
589
590    #[test]
591    fn update_with_new_secret_overrides() {
592        let existing = pw_node("", Some("old-cipher"));
593        let mut incoming = pw_node("brand-new-password", None);
594        preserve_node_secrets(&existing, &mut incoming);
595        let NodePlacement::Ssh(t) = &incoming.placement else {
596            panic!()
597        };
598        let SshAuth::Password {
599            password,
600            password_encrypted,
601        } = &t.auth
602        else {
603            panic!()
604        };
605        assert_eq!(password, "brand-new-password", "new plaintext kept");
606        assert!(
607            password_encrypted.is_none(),
608            "no carry-forward; refresh will encrypt the new plaintext"
609        );
610    }
611
612    #[test]
613    fn changing_auth_method_does_not_carry_secret() {
614        let existing = pw_node("", Some("old-cipher"));
615        let mut incoming = pw_node("", None);
616        incoming.placement = NodePlacement::Ssh(SshTarget {
617            host: "h".into(),
618            port: 22,
619            username: "u".into(),
620            auth: SshAuth::SystemSshConfig,
621            host_key_fingerprint: None,
622        });
623        // Should be a no-op (variant changed) — no panic, no carry.
624        preserve_node_secrets(&existing, &mut incoming);
625        let NodePlacement::Ssh(t) = &incoming.placement else {
626            panic!()
627        };
628        assert!(matches!(t.auth, SshAuth::SystemSshConfig));
629    }
630}