1use anyhow::{Context, Result};
20use serde::{Deserialize, Serialize};
21
22use crate::config::Config;
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
29pub struct ClusterFabricConfig {
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub clusters: Vec<Cluster>,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub nodes: Vec<Node>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub health_interval_secs: Option<u64>,
40}
41
42pub const DEFAULT_HEALTH_INTERVAL_SECS: u64 = 30;
44
45impl ClusterFabricConfig {
46 pub fn is_empty(&self) -> bool {
48 self.clusters.is_empty() && self.nodes.is_empty()
49 }
50
51 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 pub fn node(&self, id: &str) -> Option<&Node> {
63 self.nodes.iter().find(|n| n.id == id)
64 }
65
66 pub fn node_mut(&mut self, id: &str) -> Option<&mut Node> {
68 self.nodes.iter_mut().find(|n| n.id == id)
69 }
70
71 pub fn cluster(&self, name: &str) -> Option<&Cluster> {
73 self.clusters.iter().find(|c| c.name == name)
74 }
75}
76
77#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct Node {
92 pub id: String,
94 pub label: String,
96 pub placement: NodePlacement,
98 #[serde(default)]
100 pub trust_level: TrustLevel,
101 #[serde(default)]
103 pub deploy: DeployProfile,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub state: Option<NodeState>,
107 #[serde(default = "default_true")]
109 pub enabled: bool,
110}
111
112fn default_true() -> bool {
113 true
114}
115
116#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(tag = "type", rename_all = "snake_case")]
119pub enum NodePlacement {
120 #[default]
122 Local,
123 Ssh(SshTarget),
125}
126
127#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
129#[serde(rename_all = "snake_case")]
130pub enum TrustLevel {
131 #[default]
133 Trusted,
134 Untrusted,
136}
137
138#[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(tag = "method", rename_all = "snake_case")]
160pub enum SshAuth {
161 SystemSshConfig,
165 Password {
167 #[serde(default)]
169 password: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 password_encrypted: Option<String>,
173 },
174 PrivateKey {
177 #[serde(default)]
179 private_key: String,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 private_key_encrypted: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 private_key_path: Option<String>,
186 #[serde(default)]
188 passphrase: String,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 passphrase_encrypted: Option<String>,
192 },
193}
194
195#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
197pub struct DeployProfile {
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub artifact_path: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub artifact_sha256: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub remote_dir: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub default_role: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub model: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub workspace: Option<String>,
216 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
219 pub auto_recover: bool,
220}
221
222#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
224pub struct NodeState {
225 pub status: NodeStatus,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub worker_id: Option<String>,
229 #[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 #[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#[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
258impl Config {
261 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 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 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
365fn 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
379fn 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 config.refresh_cluster_fabric_encrypted().unwrap();
438 config.sanitize_cluster_fabric_for_disk();
439
440 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 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 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 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 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}