use actix_web::{web, HttpResponse};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
use bamboo_config::cluster_fabric::{
Cluster, DeployProfile, Node, NodePlacement, SshAuth, TrustLevel,
};
use crate::app_state::{AppState, ConfigUpdateEffects};
use crate::error::AppError;
mod deploy;
const SECRET_MASK: &str = "****...****";
#[derive(Serialize)]
pub struct FabricListResponse {
pub nodes: Vec<Value>,
pub clusters: Vec<Cluster>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NodeUpsertRequest {
pub label: String,
pub placement: NodePlacement,
#[serde(default)]
pub trust_level: TrustLevel,
#[serde(default)]
pub deploy: DeployProfile,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
pub struct ClusterUpsertRequest {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub node_ids: Vec<String>,
}
fn validate_node(req: &NodeUpsertRequest) -> Result<(), AppError> {
if req.label.trim().is_empty() {
return Err(AppError::BadRequest("Node label is required".into()));
}
if let NodePlacement::Ssh(target) = &req.placement {
if target.host.trim().is_empty() {
return Err(AppError::BadRequest("SSH host is required".into()));
}
if target.username.trim().is_empty() {
return Err(AppError::BadRequest("SSH username is required".into()));
}
if target.port == 0 {
return Err(AppError::BadRequest("SSH port must be non-zero".into()));
}
}
Ok(())
}
fn redact_node_value(node: &Node) -> Value {
let mut value = serde_json::to_value(node).unwrap_or(Value::Null);
if let Some(auth) = value
.get_mut("placement")
.and_then(|p| p.get_mut("auth"))
.and_then(|a| a.as_object_mut())
{
for field in ["password", "private_key", "passphrase"] {
if auth.get(field).and_then(|v| v.as_str()).is_some() {
auth.insert(field.to_string(), Value::String(SECRET_MASK.to_string()));
}
auth.remove(&format!("{field}_encrypted"));
}
}
value
}
fn preserve_node_secrets(existing: &Node, incoming: &mut Node) {
let (NodePlacement::Ssh(old), NodePlacement::Ssh(new)) =
(&existing.placement, &mut incoming.placement)
else {
return;
};
match (&old.auth, &mut new.auth) {
(
SshAuth::Password {
password: old_pw,
password_encrypted: old_enc,
},
SshAuth::Password {
password,
password_encrypted,
},
) => {
preserve_secret(password, password_encrypted, old_pw, old_enc);
}
(
SshAuth::PrivateKey {
private_key: old_pk,
private_key_encrypted: old_pk_enc,
passphrase: old_pp,
passphrase_encrypted: old_pp_enc,
..
},
SshAuth::PrivateKey {
private_key,
private_key_encrypted,
passphrase,
passphrase_encrypted,
..
},
) => {
preserve_secret(private_key, private_key_encrypted, old_pk, old_pk_enc);
preserve_secret(passphrase, passphrase_encrypted, old_pp, old_pp_enc);
}
_ => {}
}
}
fn preserve_secret(
plaintext: &mut String,
encrypted: &mut Option<String>,
old_plaintext: &str,
old_encrypted: &Option<String>,
) {
let keep = plaintext.trim().is_empty() || plaintext == SECRET_MASK;
if !keep {
return;
}
plaintext.clear();
if !old_plaintext.trim().is_empty() {
*plaintext = old_plaintext.to_string();
} else if encrypted.is_none() {
*encrypted = old_encrypted.clone();
}
}
pub async fn list_nodes(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
let config = app_state.config.read().await;
let nodes = config
.cluster_fabric
.nodes
.iter()
.map(redact_node_value)
.collect();
Ok(HttpResponse::Ok().json(FabricListResponse {
nodes,
clusters: config.cluster_fabric.clusters.clone(),
}))
}
pub async fn get_node(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
let config = app_state.config.read().await;
let node = config
.cluster_fabric
.node(&id)
.ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
Ok(HttpResponse::Ok().json(redact_node_value(node)))
}
pub async fn create_node(
app_state: web::Data<AppState>,
payload: web::Json<NodeUpsertRequest>,
) -> Result<HttpResponse, AppError> {
let req = payload.into_inner();
validate_node(&req)?;
let node = Node {
id: Uuid::new_v4().to_string(),
label: req.label,
placement: req.placement,
trust_level: req.trust_level,
deploy: req.deploy,
state: None,
enabled: req.enabled,
};
let node_id = node.id.clone();
let updated = app_state
.update_config(
move |cfg| {
cfg.cluster_fabric.nodes.push(node.clone());
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
let created = updated
.cluster_fabric
.node(&node_id)
.ok_or_else(|| AppError::InternalError(anyhow::anyhow!("created node missing")))?;
Ok(HttpResponse::Created().json(redact_node_value(created)))
}
pub async fn update_node(
app_state: web::Data<AppState>,
path: web::Path<String>,
payload: web::Json<NodeUpsertRequest>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
let req = payload.into_inner();
validate_node(&req)?;
let id_for_response = id.clone();
let updated = app_state
.update_config(
move |cfg| {
let existing = cfg
.cluster_fabric
.node(&id)
.cloned()
.ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
let mut node = Node {
id: existing.id.clone(),
label: req.label.clone(),
placement: req.placement.clone(),
trust_level: req.trust_level,
deploy: req.deploy.clone(),
state: existing.state.clone(), enabled: req.enabled,
};
preserve_node_secrets(&existing, &mut node);
let slot = cfg
.cluster_fabric
.node_mut(&id)
.expect("node existed above");
*slot = node;
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
let node = updated
.cluster_fabric
.node(&id_for_response)
.ok_or_else(|| AppError::InternalError(anyhow::anyhow!("updated node missing")))?;
Ok(HttpResponse::Ok().json(redact_node_value(node)))
}
pub async fn delete_node(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
app_state
.update_config(
move |cfg| {
let before = cfg.cluster_fabric.nodes.len();
cfg.cluster_fabric.nodes.retain(|n| n.id != id);
if cfg.cluster_fabric.nodes.len() == before {
return Err(AppError::NotFound(format!("Node '{id}'")));
}
for cluster in &mut cfg.cluster_fabric.clusters {
cluster.node_ids.retain(|nid| nid != &id);
}
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Ok().json(json!({ "success": true })))
}
pub async fn create_cluster(
app_state: web::Data<AppState>,
payload: web::Json<ClusterUpsertRequest>,
) -> Result<HttpResponse, AppError> {
let req = payload.into_inner();
if req.name.trim().is_empty() {
return Err(AppError::BadRequest("Cluster name is required".into()));
}
let updated = app_state
.update_config(
move |cfg| {
if cfg.cluster_fabric.cluster(&req.name).is_some() {
return Err(AppError::BadRequest(format!(
"Cluster '{}' already exists",
req.name
)));
}
cfg.cluster_fabric.clusters.push(Cluster {
name: req.name.clone(),
description: req.description.clone(),
node_ids: req.node_ids.clone(),
});
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Created().json(json!({ "clusters": updated.cluster_fabric.clusters })))
}
pub async fn update_cluster(
app_state: web::Data<AppState>,
path: web::Path<String>,
payload: web::Json<ClusterUpsertRequest>,
) -> Result<HttpResponse, AppError> {
let name = path.into_inner();
let req = payload.into_inner();
let updated = app_state
.update_config(
move |cfg| {
let cluster = cfg
.cluster_fabric
.clusters
.iter_mut()
.find(|c| c.name == name)
.ok_or_else(|| AppError::NotFound(format!("Cluster '{name}'")))?;
cluster.description = req.description.clone();
cluster.node_ids = req.node_ids.clone();
if !req.name.trim().is_empty() {
cluster.name = req.name.clone();
}
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Ok().json(json!({ "clusters": updated.cluster_fabric.clusters })))
}
pub async fn delete_cluster(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let name = path.into_inner();
let updated = app_state
.update_config(
move |cfg| {
let before = cfg.cluster_fabric.clusters.len();
cfg.cluster_fabric.clusters.retain(|c| c.name != name);
if cfg.cluster_fabric.clusters.len() == before {
return Err(AppError::NotFound(format!("Cluster '{name}'")));
}
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Ok().json(json!({ "clusters": updated.cluster_fabric.clusters })))
}
pub async fn node_status(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
let config = app_state.config.read().await;
let node = config
.cluster_fabric
.node(&id)
.ok_or_else(|| AppError::NotFound(format!("Node '{id}'")))?;
Ok(HttpResponse::Ok().json(json!({
"id": node.id,
"enabled": node.enabled,
"state": node.state,
})))
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeployQuery {
#[serde(default)]
pub echo: bool,
}
pub async fn node_deploy(
app_state: web::Data<AppState>,
path: web::Path<String>,
query: web::Query<DeployQuery>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
let state = deploy::deploy_node(&app_state, &id, query.echo).await?;
Ok(HttpResponse::Ok().json(json!({ "id": id, "state": state })))
}
pub async fn node_stop(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
let state = deploy::stop_node(&app_state, &id).await?;
Ok(HttpResponse::Ok().json(json!({ "id": id, "state": state })))
}
pub async fn node_test(
app_state: web::Data<AppState>,
path: web::Path<String>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
let info = deploy::test_node(&app_state, &id).await?;
Ok(HttpResponse::Ok().json(json!({ "id": id, "ok": true, "preflight": info })))
}
#[derive(Debug, Clone, Deserialize)]
pub struct LogsQuery {
#[serde(default = "default_log_lines")]
pub lines: usize,
}
fn default_log_lines() -> usize {
200
}
pub async fn node_logs(
app_state: web::Data<AppState>,
path: web::Path<String>,
query: web::Query<LogsQuery>,
) -> Result<HttpResponse, AppError> {
let id = path.into_inner();
let lines = query.lines.clamp(1, 5000);
let logs = deploy::read_logs(&app_state, &id, lines).await?;
Ok(HttpResponse::Ok().json(json!({ "id": id, "logs": logs })))
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_config::cluster_fabric::SshTarget;
fn pw_node(password: &str, encrypted: Option<&str>) -> Node {
Node {
id: "n1".into(),
label: "n1".into(),
placement: NodePlacement::Ssh(SshTarget {
host: "h".into(),
port: 22,
username: "u".into(),
auth: SshAuth::Password {
password: password.into(),
password_encrypted: encrypted.map(|s| s.into()),
},
host_key_fingerprint: None,
}),
trust_level: TrustLevel::Trusted,
deploy: DeployProfile::default(),
state: None,
enabled: true,
}
}
#[test]
fn redaction_masks_password_and_strips_ciphertext() {
let node = pw_node("hunter2", Some("ciphertext"));
let v = redact_node_value(&node);
let auth = &v["placement"]["auth"];
assert_eq!(auth["password"], SECRET_MASK);
assert!(auth.get("password_encrypted").is_none());
}
#[test]
fn redaction_omits_password_when_unset() {
let mut node = pw_node("", None);
node.placement = NodePlacement::Ssh(SshTarget {
host: "h".into(),
port: 22,
username: "u".into(),
auth: SshAuth::SystemSshConfig,
host_key_fingerprint: None,
});
let v = redact_node_value(&node);
assert_eq!(v["placement"]["auth"]["method"], "system_ssh_config");
}
#[test]
fn update_with_mask_preserves_existing_ciphertext() {
let existing = pw_node("", Some("stored-cipher"));
let mut incoming = pw_node(SECRET_MASK, None);
preserve_node_secrets(&existing, &mut incoming);
let NodePlacement::Ssh(t) = &incoming.placement else {
panic!()
};
let SshAuth::Password {
password,
password_encrypted,
} = &t.auth
else {
panic!()
};
assert!(password.is_empty(), "masked plaintext cleared");
assert_eq!(
password_encrypted.as_deref(),
Some("stored-cipher"),
"old ciphertext carried forward"
);
}
#[test]
fn update_with_mask_carries_hydrated_plaintext() {
let existing = pw_node("s3cr3t", None);
let mut incoming = pw_node(SECRET_MASK, None);
preserve_node_secrets(&existing, &mut incoming);
let NodePlacement::Ssh(t) = &incoming.placement else {
panic!()
};
let SshAuth::Password { password, .. } = &t.auth else {
panic!()
};
assert_eq!(password, "s3cr3t", "hydrated plaintext carried forward");
}
#[test]
fn update_with_new_secret_overrides() {
let existing = pw_node("", Some("old-cipher"));
let mut incoming = pw_node("brand-new-password", None);
preserve_node_secrets(&existing, &mut incoming);
let NodePlacement::Ssh(t) = &incoming.placement else {
panic!()
};
let SshAuth::Password {
password,
password_encrypted,
} = &t.auth
else {
panic!()
};
assert_eq!(password, "brand-new-password", "new plaintext kept");
assert!(
password_encrypted.is_none(),
"no carry-forward; refresh will encrypt the new plaintext"
);
}
#[test]
fn changing_auth_method_does_not_carry_secret() {
let existing = pw_node("", Some("old-cipher"));
let mut incoming = pw_node("", None);
incoming.placement = NodePlacement::Ssh(SshTarget {
host: "h".into(),
port: 22,
username: "u".into(),
auth: SshAuth::SystemSshConfig,
host_key_fingerprint: None,
});
preserve_node_secrets(&existing, &mut incoming);
let NodePlacement::Ssh(t) = &incoming.placement else {
panic!()
};
assert!(matches!(t.auth, SshAuth::SystemSshConfig));
}
}