mielin_cli/
remote.rs

1//! Remote Management for MielinCTL
2//!
3//! Provides capabilities to manage remote MielinOS nodes through
4//! the CLI, enabling centralized control of distributed deployments.
5
6use 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/// Remote node configuration
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RemoteNode {
16    /// Unique node identifier
17    pub id: String,
18    /// Display name
19    pub name: String,
20    /// Node address (host:port)
21    pub address: String,
22    /// Authentication method
23    pub auth: AuthMethod,
24    /// Connection options
25    #[serde(default)]
26    pub options: ConnectionOptions,
27    /// Node tags for grouping
28    #[serde(default)]
29    pub tags: Vec<String>,
30    /// Node description
31    #[serde(default)]
32    pub description: String,
33}
34
35/// Authentication method for remote connections
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "type", rename_all = "lowercase")]
38pub enum AuthMethod {
39    /// No authentication (insecure)
40    None,
41    /// API key authentication
42    ApiKey {
43        /// API key value
44        key: String,
45    },
46    /// Certificate-based authentication
47    Certificate {
48        /// Path to client certificate
49        cert_path: String,
50        /// Path to private key
51        key_path: String,
52        /// Optional CA certificate path
53        ca_path: Option<String>,
54    },
55    /// Token-based authentication
56    Token {
57        /// Bearer token
58        token: String,
59    },
60}
61
62/// Connection options for remote nodes
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ConnectionOptions {
65    /// Connection timeout in seconds
66    #[serde(default = "default_timeout")]
67    pub timeout_secs: u64,
68    /// Enable TLS
69    #[serde(default = "default_true")]
70    pub tls: bool,
71    /// Verify SSL certificates
72    #[serde(default = "default_true")]
73    pub verify_ssl: bool,
74    /// Maximum retry attempts
75    #[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/// Remote command execution request
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RemoteCommand {
105    /// Command to execute
106    pub command: String,
107    /// Command arguments
108    pub args: Vec<String>,
109    /// Environment variables
110    #[serde(default)]
111    pub env: HashMap<String, String>,
112}
113
114/// Remote command execution result
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct RemoteCommandResult {
117    /// Node ID
118    pub node_id: String,
119    /// Exit code
120    pub exit_code: i32,
121    /// Standard output
122    pub stdout: String,
123    /// Standard error
124    pub stderr: String,
125    /// Execution duration in milliseconds
126    pub duration_ms: u64,
127}
128
129/// Remote node manager
130pub struct RemoteManager {
131    /// Remote nodes configuration
132    nodes: HashMap<String, RemoteNode>,
133    /// Configuration file path
134    config_path: PathBuf,
135}
136
137impl RemoteManager {
138    /// Create a new remote manager
139    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        // Load existing configuration if it exists
148        if manager.config_path.exists() {
149            manager.load_config()?;
150        }
151
152        Ok(manager)
153    }
154
155    /// Get the default configuration file path
156    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        // Create directory if it doesn't exist
162        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    /// Load remote nodes configuration from file
170    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    /// Save remote nodes configuration to file
189    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    /// Add a remote node
206    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    /// Remove a remote node
220    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    /// Get a remote node by ID
233    pub fn get_node(&self, id: &str) -> Option<&RemoteNode> {
234        self.nodes.get(id)
235    }
236
237    /// List all remote nodes
238    pub fn list_nodes(&self) -> Vec<&RemoteNode> {
239        self.nodes.values().collect()
240    }
241
242    /// List nodes by tag
243    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    /// Update a remote node
251    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    /// Execute a command on a remote node
264    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        // Execute command on remote node via HTTP
281        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    /// Execute command on remote node via HTTP
295    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        // Build HTTP client with configured options
308        let mut client_builder = reqwest::Client::builder()
309            .timeout(std::time::Duration::from_secs(node.options.timeout_secs));
310
311        // Configure TLS if enabled
312        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        // Construct API endpoint URL
321        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        // Build request with authentication
328        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        // Add authentication header based on method
335        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                // Certificate-based auth would be configured in the TLS client builder
343                request_builder
344            }
345        };
346
347        // Execute request with retry logic
348        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                        // Parse successful response
366                        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                        // Handle error response
389                        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        // All retries exhausted
410        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    /// Test connection to a remote node
427    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        // Build HTTP client with configured options
435        let mut client_builder = reqwest::Client::builder()
436            .timeout(std::time::Duration::from_secs(node.options.timeout_secs));
437
438        // Configure TLS if enabled
439        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        // Construct health check URL
448        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        // Build request with authentication
455        let mut request_builder = client.get(&url);
456
457        // Add authentication header based on method
458        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                // Certificate-based auth would be configured in the TLS client builder
466                request_builder
467            }
468        };
469
470        // Execute request with retry logic
471        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        // All connection attempts failed
498        warn!(
499            "Connection test failed for {} after {} attempts",
500            node.name, node.options.max_retries
501        );
502        Ok(false)
503    }
504
505    /// Execute command on multiple nodes
506    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    /// Import nodes from a configuration file
533    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    /// Export nodes to a configuration file
556    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        // Note: manager may load existing nodes from config file
637        // Just verify it was created successfully - no need to check length
638    }
639
640    #[test]
641    fn test_add_and_remove_node() {
642        let mut manager = RemoteManager::new().unwrap();
643
644        // Use timestamp-based unique ID to avoid conflicts with persistent config
645        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        // Use timestamp-based unique IDs to avoid conflicts with persistent config
672        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        // Clean up
706        let _ = manager.remove_node(&node1_id);
707        let _ = manager.remove_node(&node2_id);
708    }
709}