1use 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
27const SECRET_MASK: &str = "****...****";
30
31#[derive(Serialize)]
35pub struct FabricListResponse {
36 pub nodes: Vec<Value>,
37 pub clusters: Vec<Cluster>,
38}
39
40#[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#[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
67fn 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
87fn 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 auth.remove(&format!("{field}_encrypted"));
103 }
104 }
105 value
106}
107
108fn 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
158fn 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 *plaintext = old_plaintext.to_string();
175 } else if encrypted.is_none() {
176 *encrypted = old_encrypted.clone();
178 }
179}
180
181pub 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
198pub 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
212pub 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
248pub 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(), 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
297pub 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 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
325pub 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
360pub 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 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
393pub 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
417pub 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#[derive(Debug, Clone, Deserialize)]
439pub struct DeployQuery {
440 #[serde(default)]
442 pub echo: bool,
443}
444
445pub 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
456pub 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
466pub 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#[derive(Debug, Clone, Deserialize)]
478pub struct LogsQuery {
479 #[serde(default = "default_log_lines")]
481 pub lines: usize,
482}
483
484fn default_log_lines() -> usize {
485 200
486}
487
488pub 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 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 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 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}