1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tracing::{debug, info, warn};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RemoteNode {
16 pub id: String,
18 pub name: String,
20 pub address: String,
22 pub auth: AuthMethod,
24 #[serde(default)]
26 pub options: ConnectionOptions,
27 #[serde(default)]
29 pub tags: Vec<String>,
30 #[serde(default)]
32 pub description: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "type", rename_all = "lowercase")]
38pub enum AuthMethod {
39 None,
41 ApiKey {
43 key: String,
45 },
46 Certificate {
48 cert_path: String,
50 key_path: String,
52 ca_path: Option<String>,
54 },
55 Token {
57 token: String,
59 },
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ConnectionOptions {
65 #[serde(default = "default_timeout")]
67 pub timeout_secs: u64,
68 #[serde(default = "default_true")]
70 pub tls: bool,
71 #[serde(default = "default_true")]
73 pub verify_ssl: bool,
74 #[serde(default = "default_retries")]
76 pub max_retries: u32,
77}
78
79fn default_timeout() -> u64 {
80 30
81}
82
83fn default_true() -> bool {
84 true
85}
86
87fn default_retries() -> u32 {
88 3
89}
90
91impl Default for ConnectionOptions {
92 fn default() -> Self {
93 Self {
94 timeout_secs: default_timeout(),
95 tls: default_true(),
96 verify_ssl: default_true(),
97 max_retries: default_retries(),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RemoteCommand {
105 pub command: String,
107 pub args: Vec<String>,
109 #[serde(default)]
111 pub env: HashMap<String, String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct RemoteCommandResult {
117 pub node_id: String,
119 pub exit_code: i32,
121 pub stdout: String,
123 pub stderr: String,
125 pub duration_ms: u64,
127}
128
129pub struct RemoteManager {
131 nodes: HashMap<String, RemoteNode>,
133 config_path: PathBuf,
135}
136
137impl RemoteManager {
138 pub fn new() -> Result<Self> {
140 let config_path = Self::get_config_path()?;
141
142 let mut manager = RemoteManager {
143 nodes: HashMap::new(),
144 config_path,
145 };
146
147 if manager.config_path.exists() {
149 manager.load_config()?;
150 }
151
152 Ok(manager)
153 }
154
155 pub fn get_config_path() -> Result<PathBuf> {
157 let config_dir =
158 dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Failed to get config directory"))?;
159 let mielin_dir = config_dir.join("mielin");
160
161 if !mielin_dir.exists() {
163 fs::create_dir_all(&mielin_dir).context("Failed to create mielin config directory")?;
164 }
165
166 Ok(mielin_dir.join("remote_nodes.toml"))
167 }
168
169 pub fn load_config(&mut self) -> Result<()> {
171 debug!(
172 "Loading remote nodes configuration from {:?}",
173 self.config_path
174 );
175
176 let content = fs::read_to_string(&self.config_path)
177 .context("Failed to read remote nodes configuration")?;
178
179 let nodes: HashMap<String, RemoteNode> =
180 toml::from_str(&content).context("Failed to parse remote nodes configuration")?;
181
182 self.nodes = nodes;
183 info!("Loaded {} remote node(s)", self.nodes.len());
184
185 Ok(())
186 }
187
188 pub fn save_config(&self) -> Result<()> {
190 debug!(
191 "Saving remote nodes configuration to {:?}",
192 self.config_path
193 );
194
195 let content = toml::to_string_pretty(&self.nodes)
196 .context("Failed to serialize remote nodes configuration")?;
197
198 fs::write(&self.config_path, content)
199 .context("Failed to write remote nodes configuration")?;
200
201 info!("Saved {} remote node(s)", self.nodes.len());
202 Ok(())
203 }
204
205 pub fn add_node(&mut self, node: RemoteNode) -> Result<()> {
207 if self.nodes.contains_key(&node.id) {
208 anyhow::bail!("Remote node already exists: {}", node.id);
209 }
210
211 let id = node.id.clone();
212 self.nodes.insert(id.clone(), node);
213 self.save_config()?;
214
215 info!("Added remote node: {}", id);
216 Ok(())
217 }
218
219 pub fn remove_node(&mut self, id: &str) -> Result<()> {
221 if !self.nodes.contains_key(id) {
222 anyhow::bail!("Remote node not found: {}", id);
223 }
224
225 self.nodes.remove(id);
226 self.save_config()?;
227
228 info!("Removed remote node: {}", id);
229 Ok(())
230 }
231
232 pub fn get_node(&self, id: &str) -> Option<&RemoteNode> {
234 self.nodes.get(id)
235 }
236
237 pub fn list_nodes(&self) -> Vec<&RemoteNode> {
239 self.nodes.values().collect()
240 }
241
242 pub fn list_nodes_by_tag(&self, tag: &str) -> Vec<&RemoteNode> {
244 self.nodes
245 .values()
246 .filter(|n| n.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)))
247 .collect()
248 }
249
250 pub fn update_node(&mut self, id: &str, node: RemoteNode) -> Result<()> {
252 if !self.nodes.contains_key(id) {
253 anyhow::bail!("Remote node not found: {}", id);
254 }
255
256 self.nodes.insert(id.to_string(), node);
257 self.save_config()?;
258
259 info!("Updated remote node: {}", id);
260 Ok(())
261 }
262
263 pub async fn execute_command(
265 &self,
266 node_id: &str,
267 command: RemoteCommand,
268 ) -> Result<RemoteCommandResult> {
269 let node = self
270 .get_node(node_id)
271 .ok_or_else(|| anyhow::anyhow!("Remote node not found: {}", node_id))?;
272
273 debug!(
274 "Executing command on remote node {}: {}",
275 node_id, command.command
276 );
277
278 let start_time = std::time::Instant::now();
279
280 let result = self.execute_remote_command(node, &command).await?;
282
283 let duration_ms = start_time.elapsed().as_millis() as u64;
284
285 Ok(RemoteCommandResult {
286 node_id: node_id.to_string(),
287 exit_code: result.exit_code,
288 stdout: result.stdout,
289 stderr: result.stderr,
290 duration_ms,
291 })
292 }
293
294 async fn execute_remote_command(
296 &self,
297 node: &RemoteNode,
298 command: &RemoteCommand,
299 ) -> Result<RemoteCommandResult> {
300 debug!(
301 "Executing remote command on {}: {}",
302 node.address, command.command
303 );
304
305 let start_time = std::time::Instant::now();
306
307 let mut client_builder = reqwest::Client::builder()
309 .timeout(std::time::Duration::from_secs(node.options.timeout_secs));
310
311 if node.options.tls {
313 client_builder = client_builder.danger_accept_invalid_certs(!node.options.verify_ssl);
314 }
315
316 let client = client_builder
317 .build()
318 .context("Failed to build HTTP client")?;
319
320 let url = if node.options.tls {
322 format!("https://{}/api/v1/command", node.address)
323 } else {
324 format!("http://{}/api/v1/command", node.address)
325 };
326
327 let mut request_builder = client.post(&url).json(&serde_json::json!({
329 "command": command.command,
330 "args": command.args,
331 "env": command.env,
332 }));
333
334 request_builder = match &node.auth {
336 AuthMethod::None => request_builder,
337 AuthMethod::ApiKey { key } => request_builder.header("X-API-Key", key),
338 AuthMethod::Token { token } => {
339 request_builder.header("Authorization", format!("Bearer {}", token))
340 }
341 AuthMethod::Certificate { .. } => {
342 request_builder
344 }
345 };
346
347 let mut last_error = None;
349 for attempt in 0..node.options.max_retries {
350 if attempt > 0 {
351 debug!("Retrying command execution (attempt {})", attempt + 1);
352 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
353 }
354
355 match request_builder
356 .try_clone()
357 .ok_or_else(|| anyhow::anyhow!("Failed to clone request"))?
358 .send()
359 .await
360 {
361 Ok(response) => {
362 let duration_ms = start_time.elapsed().as_millis() as u64;
363
364 if response.status().is_success() {
365 let result: serde_json::Value =
367 response.json().await.context("Failed to parse response")?;
368
369 return Ok(RemoteCommandResult {
370 node_id: node.id.clone(),
371 exit_code: result
372 .get("exit_code")
373 .and_then(|v| v.as_i64())
374 .unwrap_or(0) as i32,
375 stdout: result
376 .get("stdout")
377 .and_then(|v| v.as_str())
378 .unwrap_or("")
379 .to_string(),
380 stderr: result
381 .get("stderr")
382 .and_then(|v| v.as_str())
383 .unwrap_or("")
384 .to_string(),
385 duration_ms,
386 });
387 } else {
388 let error_text = response
390 .text()
391 .await
392 .unwrap_or_else(|_| "Unknown error".to_string());
393
394 return Ok(RemoteCommandResult {
395 node_id: node.id.clone(),
396 exit_code: 1,
397 stdout: String::new(),
398 stderr: format!("HTTP error: {}", error_text),
399 duration_ms,
400 });
401 }
402 }
403 Err(e) => {
404 last_error = Some(e);
405 }
406 }
407 }
408
409 let duration_ms = start_time.elapsed().as_millis() as u64;
411 Ok(RemoteCommandResult {
412 node_id: node.id.clone(),
413 exit_code: 1,
414 stdout: String::new(),
415 stderr: format!(
416 "Connection failed after {} attempts: {}",
417 node.options.max_retries,
418 last_error
419 .map(|e| e.to_string())
420 .unwrap_or_else(|| "Unknown error".to_string())
421 ),
422 duration_ms,
423 })
424 }
425
426 pub async fn test_connection(&self, node_id: &str) -> Result<bool> {
428 let node = self
429 .get_node(node_id)
430 .ok_or_else(|| anyhow::anyhow!("Remote node not found: {}", node_id))?;
431
432 debug!("Testing connection to remote node: {}", node.address);
433
434 let mut client_builder = reqwest::Client::builder()
436 .timeout(std::time::Duration::from_secs(node.options.timeout_secs));
437
438 if node.options.tls {
440 client_builder = client_builder.danger_accept_invalid_certs(!node.options.verify_ssl);
441 }
442
443 let client = client_builder
444 .build()
445 .context("Failed to build HTTP client")?;
446
447 let url = if node.options.tls {
449 format!("https://{}/api/v1/health", node.address)
450 } else {
451 format!("http://{}/api/v1/health", node.address)
452 };
453
454 let mut request_builder = client.get(&url);
456
457 request_builder = match &node.auth {
459 AuthMethod::None => request_builder,
460 AuthMethod::ApiKey { key } => request_builder.header("X-API-Key", key),
461 AuthMethod::Token { token } => {
462 request_builder.header("Authorization", format!("Bearer {}", token))
463 }
464 AuthMethod::Certificate { .. } => {
465 request_builder
467 }
468 };
469
470 for attempt in 0..node.options.max_retries {
472 if attempt > 0 {
473 debug!("Retrying connection test (attempt {})", attempt + 1);
474 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
475 }
476
477 match request_builder
478 .try_clone()
479 .ok_or_else(|| anyhow::anyhow!("Failed to clone request"))?
480 .send()
481 .await
482 {
483 Ok(response) => {
484 if response.status().is_success() {
485 info!("Connection test successful for {}", node.name);
486 return Ok(true);
487 } else {
488 debug!("Connection test failed with status: {}", response.status());
489 }
490 }
491 Err(e) => {
492 debug!("Connection attempt {} failed: {}", attempt + 1, e);
493 }
494 }
495 }
496
497 warn!(
499 "Connection test failed for {} after {} attempts",
500 node.name, node.options.max_retries
501 );
502 Ok(false)
503 }
504
505 pub async fn execute_on_multiple(
507 &self,
508 node_ids: &[String],
509 command: RemoteCommand,
510 ) -> Result<Vec<RemoteCommandResult>> {
511 let mut results = Vec::new();
512
513 for node_id in node_ids {
514 match self.execute_command(node_id, command.clone()).await {
515 Ok(result) => results.push(result),
516 Err(e) => {
517 warn!("Failed to execute command on {}: {}", node_id, e);
518 results.push(RemoteCommandResult {
519 node_id: node_id.clone(),
520 exit_code: 1,
521 stdout: String::new(),
522 stderr: format!("Error: {}", e),
523 duration_ms: 0,
524 });
525 }
526 }
527 }
528
529 Ok(results)
530 }
531
532 pub fn import_nodes(&mut self, path: &Path) -> Result<usize> {
534 if !path.exists() {
535 anyhow::bail!("Import file not found: {:?}", path);
536 }
537
538 let content = fs::read_to_string(path).context("Failed to read import file")?;
539
540 let imported_nodes: HashMap<String, RemoteNode> =
541 toml::from_str(&content).context("Failed to parse import file")?;
542
543 let count = imported_nodes.len();
544
545 for (id, node) in imported_nodes {
546 self.nodes.insert(id, node);
547 }
548
549 self.save_config()?;
550 info!("Imported {} remote node(s)", count);
551
552 Ok(count)
553 }
554
555 pub fn export_nodes(&self, path: &Path) -> Result<()> {
557 let content =
558 toml::to_string_pretty(&self.nodes).context("Failed to serialize nodes for export")?;
559
560 fs::write(path, content).context("Failed to write export file")?;
561
562 info!("Exported {} remote node(s) to {:?}", self.nodes.len(), path);
563 Ok(())
564 }
565}
566
567impl Default for RemoteManager {
568 fn default() -> Self {
569 Self::new().expect("Failed to create remote manager")
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use std::time::SystemTime;
577
578 #[test]
579 fn test_auth_method_serialization() {
580 let auth = AuthMethod::ApiKey {
581 key: "test-key".to_string(),
582 };
583
584 let toml_str = toml::to_string(&auth).unwrap();
585 assert!(toml_str.contains("apikey"));
586 assert!(toml_str.contains("test-key"));
587 }
588
589 #[test]
590 fn test_connection_options_default() {
591 let options = ConnectionOptions::default();
592
593 assert_eq!(options.timeout_secs, 30);
594 assert!(options.tls);
595 assert!(options.verify_ssl);
596 assert_eq!(options.max_retries, 3);
597 }
598
599 #[test]
600 fn test_remote_node_serialization() {
601 let node = RemoteNode {
602 id: "node1".to_string(),
603 name: "Test Node".to_string(),
604 address: "localhost:8080".to_string(),
605 auth: AuthMethod::None,
606 options: ConnectionOptions::default(),
607 tags: vec!["test".to_string()],
608 description: "A test node".to_string(),
609 };
610
611 let toml_str = toml::to_string(&node).unwrap();
612 assert!(toml_str.contains("node1"));
613 assert!(toml_str.contains("Test Node"));
614 }
615
616 #[test]
617 fn test_remote_command() {
618 let mut env = HashMap::new();
619 env.insert("TEST".to_string(), "value".to_string());
620
621 let cmd = RemoteCommand {
622 command: "test".to_string(),
623 args: vec!["arg1".to_string()],
624 env,
625 };
626
627 assert_eq!(cmd.command, "test");
628 assert_eq!(cmd.args.len(), 1);
629 }
630
631 #[test]
632 fn test_remote_manager_creation() {
633 let manager = RemoteManager::new();
634 assert!(manager.is_ok());
635
636 }
639
640 #[test]
641 fn test_add_and_remove_node() {
642 let mut manager = RemoteManager::new().unwrap();
643
644 let timestamp = SystemTime::now()
646 .duration_since(SystemTime::UNIX_EPOCH)
647 .unwrap()
648 .as_micros();
649 let node_id = format!("test-node-{}", timestamp);
650
651 let node = RemoteNode {
652 id: node_id.clone(),
653 name: "Test Node".to_string(),
654 address: "localhost:8080".to_string(),
655 auth: AuthMethod::None,
656 options: ConnectionOptions::default(),
657 tags: vec![],
658 description: String::new(),
659 };
660
661 assert!(manager.add_node(node.clone()).is_ok());
662 assert!(manager.get_node(&node_id).is_some());
663 assert!(manager.remove_node(&node_id).is_ok());
664 assert!(manager.get_node(&node_id).is_none());
665 }
666
667 #[test]
668 fn test_list_nodes_by_tag() {
669 let mut manager = RemoteManager::new().unwrap();
670
671 let timestamp = SystemTime::now()
673 .duration_since(SystemTime::UNIX_EPOCH)
674 .unwrap()
675 .as_micros();
676 let node1_id = format!("test-tag-node1-{}", timestamp);
677 let node2_id = format!("test-tag-node2-{}", timestamp);
678
679 let node1 = RemoteNode {
680 id: node1_id.clone(),
681 name: "Node 1".to_string(),
682 address: "localhost:8080".to_string(),
683 auth: AuthMethod::None,
684 options: ConnectionOptions::default(),
685 tags: vec!["prod".to_string()],
686 description: String::new(),
687 };
688
689 let node2 = RemoteNode {
690 id: node2_id.clone(),
691 name: "Node 2".to_string(),
692 address: "localhost:8081".to_string(),
693 auth: AuthMethod::None,
694 options: ConnectionOptions::default(),
695 tags: vec!["dev".to_string()],
696 description: String::new(),
697 };
698
699 let _ = manager.add_node(node1);
700 let _ = manager.add_node(node2);
701
702 let prod_nodes = manager.list_nodes_by_tag("prod");
703 assert!(prod_nodes.iter().any(|n| n.id == node1_id));
704
705 let _ = manager.remove_node(&node1_id);
707 let _ = manager.remove_node(&node2_id);
708 }
709}