use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::Config;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ClusterFabricConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clusters: Vec<Cluster>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nodes: Vec<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_interval_secs: Option<u64>,
}
pub const DEFAULT_HEALTH_INTERVAL_SECS: u64 = 30;
impl ClusterFabricConfig {
pub fn is_empty(&self) -> bool {
self.clusters.is_empty() && self.nodes.is_empty()
}
pub fn health_interval(&self) -> Option<std::time::Duration> {
match self.health_interval_secs {
Some(0) => None,
Some(s) => Some(std::time::Duration::from_secs(s)),
None => Some(std::time::Duration::from_secs(DEFAULT_HEALTH_INTERVAL_SECS)),
}
}
pub fn node(&self, id: &str) -> Option<&Node> {
self.nodes.iter().find(|n| n.id == id)
}
pub fn node_mut(&mut self, id: &str) -> Option<&mut Node> {
self.nodes.iter_mut().find(|n| n.id == id)
}
pub fn cluster(&self, name: &str) -> Option<&Cluster> {
self.clusters.iter().find(|c| c.name == name)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cluster {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub node_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Node {
pub id: String,
pub label: String,
pub placement: NodePlacement,
#[serde(default)]
pub trust_level: TrustLevel,
#[serde(default)]
pub deploy: DeployProfile,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<NodeState>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NodePlacement {
#[default]
Local,
Ssh(SshTarget),
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TrustLevel {
#[default]
Trusted,
Untrusted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SshTarget {
pub host: String,
#[serde(default = "default_ssh_port")]
pub port: u16,
pub username: String,
pub auth: SshAuth,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_key_fingerprint: Option<String>,
}
fn default_ssh_port() -> u16 {
22
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum SshAuth {
SystemSshConfig,
Password {
#[serde(default)]
password: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
password_encrypted: Option<String>,
},
PrivateKey {
#[serde(default)]
private_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
private_key_encrypted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
private_key_path: Option<String>,
#[serde(default)]
passphrase: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
passphrase_encrypted: Option<String>,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeployProfile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub auto_recover: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeState {
pub status: NodeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployed_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_health: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeStatus {
#[default]
NotDeployed,
Deploying,
Running,
Unreachable,
Stopped,
Failed,
}
impl Config {
pub fn hydrate_cluster_fabric_from_encrypted(&mut self) {
for node in &mut self.cluster_fabric.nodes {
let NodePlacement::Ssh(target) = &mut node.placement else {
continue;
};
match &mut target.auth {
SshAuth::SystemSshConfig => {}
SshAuth::Password {
password,
password_encrypted,
} => {
hydrate_field(
password,
password_encrypted.as_deref(),
&node.id,
"password",
);
}
SshAuth::PrivateKey {
private_key,
private_key_encrypted,
passphrase,
passphrase_encrypted,
..
} => {
hydrate_field(
private_key,
private_key_encrypted.as_deref(),
&node.id,
"private_key",
);
hydrate_field(
passphrase,
passphrase_encrypted.as_deref(),
&node.id,
"passphrase",
);
}
}
}
}
pub fn refresh_cluster_fabric_encrypted(&mut self) -> Result<()> {
for node in &mut self.cluster_fabric.nodes {
let node_id = node.id.clone();
let NodePlacement::Ssh(target) = &mut node.placement else {
continue;
};
match &mut target.auth {
SshAuth::SystemSshConfig => {}
SshAuth::Password {
password,
password_encrypted,
} => {
refresh_field(password, password_encrypted, &node_id, "password")?;
}
SshAuth::PrivateKey {
private_key,
private_key_encrypted,
passphrase,
passphrase_encrypted,
..
} => {
refresh_field(private_key, private_key_encrypted, &node_id, "private_key")?;
refresh_field(passphrase, passphrase_encrypted, &node_id, "passphrase")?;
}
}
}
Ok(())
}
pub fn sanitize_cluster_fabric_for_disk(&mut self) {
for node in &mut self.cluster_fabric.nodes {
let NodePlacement::Ssh(target) = &mut node.placement else {
continue;
};
match &mut target.auth {
SshAuth::SystemSshConfig => {}
SshAuth::Password { password, .. } => password.clear(),
SshAuth::PrivateKey {
private_key,
passphrase,
..
} => {
private_key.clear();
passphrase.clear();
}
}
}
}
}
fn hydrate_field(plaintext: &mut String, encrypted: Option<&str>, node_id: &str, what: &str) {
if !plaintext.trim().is_empty() {
return;
}
let Some(encrypted) = encrypted else {
return;
};
match crate::encryption::decrypt(encrypted) {
Ok(value) => *plaintext = value,
Err(e) => tracing::warn!("Failed to decrypt node '{node_id}' {what}: {e}"),
}
}
fn refresh_field(
plaintext: &str,
encrypted: &mut Option<String>,
node_id: &str,
what: &str,
) -> Result<()> {
if plaintext.trim().is_empty() {
return Ok(());
}
*encrypted = Some(
crate::encryption::encrypt(plaintext)
.with_context(|| format!("Failed to encrypt node '{node_id}' {what}"))?,
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ssh_node(id: &str, auth: SshAuth) -> Node {
Node {
id: id.to_string(),
label: id.to_string(),
placement: NodePlacement::Ssh(SshTarget {
host: "10.0.0.1".to_string(),
port: 22,
username: "deploy".to_string(),
auth,
host_key_fingerprint: None,
}),
trust_level: TrustLevel::Trusted,
deploy: DeployProfile::default(),
state: None,
enabled: true,
}
}
#[test]
fn empty_fabric_is_skipped_on_serialize() {
let cfg = ClusterFabricConfig::default();
assert!(cfg.is_empty());
}
#[test]
fn password_secret_round_trips_through_encrypt_sanitize_hydrate() {
let mut config = Config::default();
config.cluster_fabric.nodes.push(ssh_node(
"n1",
SshAuth::Password {
password: "hunter2".to_string(),
password_encrypted: None,
},
));
config.refresh_cluster_fabric_encrypted().unwrap();
config.sanitize_cluster_fabric_for_disk();
let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
panic!("expected ssh");
};
let SshAuth::Password {
password,
password_encrypted,
} = &t.auth
else {
panic!("expected password auth");
};
assert!(password.is_empty(), "plaintext must be cleared for disk");
assert!(password_encrypted.is_some(), "ciphertext must be stored");
config.hydrate_cluster_fabric_from_encrypted();
let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
panic!("expected ssh");
};
let SshAuth::Password { password, .. } = &t.auth else {
panic!("expected password auth");
};
assert_eq!(password, "hunter2", "plaintext restored on hydrate");
}
#[test]
fn private_key_and_passphrase_round_trip() {
let mut config = Config::default();
config.cluster_fabric.nodes.push(ssh_node(
"n2",
SshAuth::PrivateKey {
private_key: "-----BEGIN KEY-----xyz".to_string(),
private_key_encrypted: None,
private_key_path: None,
passphrase: "pp".to_string(),
passphrase_encrypted: None,
},
));
config.refresh_cluster_fabric_encrypted().unwrap();
config.sanitize_cluster_fabric_for_disk();
config.hydrate_cluster_fabric_from_encrypted();
let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
panic!("expected ssh");
};
let SshAuth::PrivateKey {
private_key,
passphrase,
..
} = &t.auth
else {
panic!("expected private key auth");
};
assert_eq!(private_key, "-----BEGIN KEY-----xyz");
assert_eq!(passphrase, "pp");
}
#[test]
fn empty_plaintext_keeps_existing_ciphertext_on_refresh() {
let mut config = Config::default();
config.cluster_fabric.nodes.push(ssh_node(
"n3",
SshAuth::Password {
password: "secret".to_string(),
password_encrypted: None,
},
));
config.refresh_cluster_fabric_encrypted().unwrap();
config.sanitize_cluster_fabric_for_disk();
config.refresh_cluster_fabric_encrypted().unwrap();
config.hydrate_cluster_fabric_from_encrypted();
let NodePlacement::Ssh(t) = &config.cluster_fabric.nodes[0].placement else {
panic!("expected ssh");
};
let SshAuth::Password { password, .. } = &t.auth else {
panic!("expected password auth");
};
assert_eq!(
password, "secret",
"ciphertext preserved across empty refresh"
);
}
#[test]
fn local_node_has_no_secrets_to_touch() {
let mut config = Config::default();
config.cluster_fabric.nodes.push(Node {
id: "local".to_string(),
label: "local".to_string(),
placement: NodePlacement::Local,
trust_level: TrustLevel::Trusted,
deploy: DeployProfile::default(),
state: None,
enabled: true,
});
config.refresh_cluster_fabric_encrypted().unwrap();
config.sanitize_cluster_fabric_for_disk();
config.hydrate_cluster_fabric_from_encrypted();
assert_eq!(config.cluster_fabric.nodes.len(), 1);
}
}