use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteNode {
pub id: String,
pub name: String,
pub address: String,
pub auth: AuthMethod,
#[serde(default)]
pub options: ConnectionOptions,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum AuthMethod {
None,
ApiKey {
key: String,
},
Certificate {
cert_path: String,
key_path: String,
ca_path: Option<String>,
},
Token {
token: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionOptions {
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_true")]
pub tls: bool,
#[serde(default = "default_true")]
pub verify_ssl: bool,
#[serde(default = "default_retries")]
pub max_retries: u32,
}
fn default_timeout() -> u64 {
30
}
fn default_true() -> bool {
true
}
fn default_retries() -> u32 {
3
}
impl Default for ConnectionOptions {
fn default() -> Self {
Self {
timeout_secs: default_timeout(),
tls: default_true(),
verify_ssl: default_true(),
max_retries: default_retries(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteCommand {
pub command: String,
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteCommandResult {
pub node_id: String,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
}
pub struct RemoteManager {
nodes: HashMap<String, RemoteNode>,
config_path: PathBuf,
}
impl RemoteManager {
pub fn new() -> Result<Self> {
let config_path = Self::get_config_path()?;
let mut manager = RemoteManager {
nodes: HashMap::new(),
config_path,
};
if manager.config_path.exists() {
manager.load_config()?;
}
Ok(manager)
}
pub fn get_config_path() -> Result<PathBuf> {
let config_dir =
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Failed to get config directory"))?;
let mielin_dir = config_dir.join("mielin");
if !mielin_dir.exists() {
fs::create_dir_all(&mielin_dir).context("Failed to create mielin config directory")?;
}
Ok(mielin_dir.join("remote_nodes.toml"))
}
pub fn load_config(&mut self) -> Result<()> {
debug!(
"Loading remote nodes configuration from {:?}",
self.config_path
);
let content = fs::read_to_string(&self.config_path)
.context("Failed to read remote nodes configuration")?;
let nodes: HashMap<String, RemoteNode> =
toml::from_str(&content).context("Failed to parse remote nodes configuration")?;
self.nodes = nodes;
info!("Loaded {} remote node(s)", self.nodes.len());
Ok(())
}
pub fn save_config(&self) -> Result<()> {
debug!(
"Saving remote nodes configuration to {:?}",
self.config_path
);
let content = toml::to_string_pretty(&self.nodes)
.context("Failed to serialize remote nodes configuration")?;
fs::write(&self.config_path, content)
.context("Failed to write remote nodes configuration")?;
info!("Saved {} remote node(s)", self.nodes.len());
Ok(())
}
pub fn add_node(&mut self, node: RemoteNode) -> Result<()> {
if self.nodes.contains_key(&node.id) {
anyhow::bail!("Remote node already exists: {}", node.id);
}
let id = node.id.clone();
self.nodes.insert(id.clone(), node);
self.save_config()?;
info!("Added remote node: {}", id);
Ok(())
}
pub fn remove_node(&mut self, id: &str) -> Result<()> {
if !self.nodes.contains_key(id) {
anyhow::bail!("Remote node not found: {}", id);
}
self.nodes.remove(id);
self.save_config()?;
info!("Removed remote node: {}", id);
Ok(())
}
pub fn get_node(&self, id: &str) -> Option<&RemoteNode> {
self.nodes.get(id)
}
pub fn list_nodes(&self) -> Vec<&RemoteNode> {
self.nodes.values().collect()
}
pub fn list_nodes_by_tag(&self, tag: &str) -> Vec<&RemoteNode> {
self.nodes
.values()
.filter(|n| n.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)))
.collect()
}
pub fn update_node(&mut self, id: &str, node: RemoteNode) -> Result<()> {
if !self.nodes.contains_key(id) {
anyhow::bail!("Remote node not found: {}", id);
}
self.nodes.insert(id.to_string(), node);
self.save_config()?;
info!("Updated remote node: {}", id);
Ok(())
}
pub async fn execute_command(
&self,
node_id: &str,
command: RemoteCommand,
) -> Result<RemoteCommandResult> {
let node = self
.get_node(node_id)
.ok_or_else(|| anyhow::anyhow!("Remote node not found: {}", node_id))?;
debug!(
"Executing command on remote node {}: {}",
node_id, command.command
);
let start_time = std::time::Instant::now();
let result = self.execute_remote_command(node, &command).await?;
let duration_ms = start_time.elapsed().as_millis() as u64;
Ok(RemoteCommandResult {
node_id: node_id.to_string(),
exit_code: result.exit_code,
stdout: result.stdout,
stderr: result.stderr,
duration_ms,
})
}
async fn execute_remote_command(
&self,
node: &RemoteNode,
command: &RemoteCommand,
) -> Result<RemoteCommandResult> {
debug!(
"Executing remote command on {}: {}",
node.address, command.command
);
let start_time = std::time::Instant::now();
let mut client_builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(node.options.timeout_secs));
if node.options.tls {
client_builder = client_builder.danger_accept_invalid_certs(!node.options.verify_ssl);
}
let client = client_builder
.build()
.context("Failed to build HTTP client")?;
let url = if node.options.tls {
format!("https://{}/api/v1/command", node.address)
} else {
format!("http://{}/api/v1/command", node.address)
};
let mut request_builder = client.post(&url).json(&serde_json::json!({
"command": command.command,
"args": command.args,
"env": command.env,
}));
request_builder = match &node.auth {
AuthMethod::None => request_builder,
AuthMethod::ApiKey { key } => request_builder.header("X-API-Key", key),
AuthMethod::Token { token } => {
request_builder.header("Authorization", format!("Bearer {}", token))
}
AuthMethod::Certificate { .. } => {
request_builder
}
};
let mut last_error = None;
for attempt in 0..node.options.max_retries {
if attempt > 0 {
debug!("Retrying command execution (attempt {})", attempt + 1);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
match request_builder
.try_clone()
.ok_or_else(|| anyhow::anyhow!("Failed to clone request"))?
.send()
.await
{
Ok(response) => {
let duration_ms = start_time.elapsed().as_millis() as u64;
if response.status().is_success() {
let result: serde_json::Value =
response.json().await.context("Failed to parse response")?;
return Ok(RemoteCommandResult {
node_id: node.id.clone(),
exit_code: result
.get("exit_code")
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32,
stdout: result
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
stderr: result
.get("stderr")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
duration_ms,
});
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Ok(RemoteCommandResult {
node_id: node.id.clone(),
exit_code: 1,
stdout: String::new(),
stderr: format!("HTTP error: {}", error_text),
duration_ms,
});
}
}
Err(e) => {
last_error = Some(e);
}
}
}
let duration_ms = start_time.elapsed().as_millis() as u64;
Ok(RemoteCommandResult {
node_id: node.id.clone(),
exit_code: 1,
stdout: String::new(),
stderr: format!(
"Connection failed after {} attempts: {}",
node.options.max_retries,
last_error
.map(|e| e.to_string())
.unwrap_or_else(|| "Unknown error".to_string())
),
duration_ms,
})
}
pub async fn test_connection(&self, node_id: &str) -> Result<bool> {
let node = self
.get_node(node_id)
.ok_or_else(|| anyhow::anyhow!("Remote node not found: {}", node_id))?;
debug!("Testing connection to remote node: {}", node.address);
let mut client_builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(node.options.timeout_secs));
if node.options.tls {
client_builder = client_builder.danger_accept_invalid_certs(!node.options.verify_ssl);
}
let client = client_builder
.build()
.context("Failed to build HTTP client")?;
let url = if node.options.tls {
format!("https://{}/api/v1/health", node.address)
} else {
format!("http://{}/api/v1/health", node.address)
};
let mut request_builder = client.get(&url);
request_builder = match &node.auth {
AuthMethod::None => request_builder,
AuthMethod::ApiKey { key } => request_builder.header("X-API-Key", key),
AuthMethod::Token { token } => {
request_builder.header("Authorization", format!("Bearer {}", token))
}
AuthMethod::Certificate { .. } => {
request_builder
}
};
for attempt in 0..node.options.max_retries {
if attempt > 0 {
debug!("Retrying connection test (attempt {})", attempt + 1);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
match request_builder
.try_clone()
.ok_or_else(|| anyhow::anyhow!("Failed to clone request"))?
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
info!("Connection test successful for {}", node.name);
return Ok(true);
} else {
debug!("Connection test failed with status: {}", response.status());
}
}
Err(e) => {
debug!("Connection attempt {} failed: {}", attempt + 1, e);
}
}
}
warn!(
"Connection test failed for {} after {} attempts",
node.name, node.options.max_retries
);
Ok(false)
}
pub async fn execute_on_multiple(
&self,
node_ids: &[String],
command: RemoteCommand,
) -> Result<Vec<RemoteCommandResult>> {
let mut results = Vec::new();
for node_id in node_ids {
match self.execute_command(node_id, command.clone()).await {
Ok(result) => results.push(result),
Err(e) => {
warn!("Failed to execute command on {}: {}", node_id, e);
results.push(RemoteCommandResult {
node_id: node_id.clone(),
exit_code: 1,
stdout: String::new(),
stderr: format!("Error: {}", e),
duration_ms: 0,
});
}
}
}
Ok(results)
}
pub fn import_nodes(&mut self, path: &Path) -> Result<usize> {
if !path.exists() {
anyhow::bail!("Import file not found: {:?}", path);
}
let content = fs::read_to_string(path).context("Failed to read import file")?;
let imported_nodes: HashMap<String, RemoteNode> =
toml::from_str(&content).context("Failed to parse import file")?;
let count = imported_nodes.len();
for (id, node) in imported_nodes {
self.nodes.insert(id, node);
}
self.save_config()?;
info!("Imported {} remote node(s)", count);
Ok(count)
}
pub fn export_nodes(&self, path: &Path) -> Result<()> {
let content =
toml::to_string_pretty(&self.nodes).context("Failed to serialize nodes for export")?;
fs::write(path, content).context("Failed to write export file")?;
info!("Exported {} remote node(s) to {:?}", self.nodes.len(), path);
Ok(())
}
}
impl Default for RemoteManager {
fn default() -> Self {
Self::new().expect("Failed to create remote manager")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::SystemTime;
#[test]
fn test_auth_method_serialization() {
let auth = AuthMethod::ApiKey {
key: "test-key".to_string(),
};
let toml_str = toml::to_string(&auth).unwrap();
assert!(toml_str.contains("apikey"));
assert!(toml_str.contains("test-key"));
}
#[test]
fn test_connection_options_default() {
let options = ConnectionOptions::default();
assert_eq!(options.timeout_secs, 30);
assert!(options.tls);
assert!(options.verify_ssl);
assert_eq!(options.max_retries, 3);
}
#[test]
fn test_remote_node_serialization() {
let node = RemoteNode {
id: "node1".to_string(),
name: "Test Node".to_string(),
address: "localhost:8080".to_string(),
auth: AuthMethod::None,
options: ConnectionOptions::default(),
tags: vec!["test".to_string()],
description: "A test node".to_string(),
};
let toml_str = toml::to_string(&node).unwrap();
assert!(toml_str.contains("node1"));
assert!(toml_str.contains("Test Node"));
}
#[test]
fn test_remote_command() {
let mut env = HashMap::new();
env.insert("TEST".to_string(), "value".to_string());
let cmd = RemoteCommand {
command: "test".to_string(),
args: vec!["arg1".to_string()],
env,
};
assert_eq!(cmd.command, "test");
assert_eq!(cmd.args.len(), 1);
}
#[test]
fn test_remote_manager_creation() {
let manager = RemoteManager::new();
assert!(manager.is_ok());
}
#[test]
fn test_add_and_remove_node() {
let mut manager = RemoteManager::new().unwrap();
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_micros();
let node_id = format!("test-node-{}", timestamp);
let node = RemoteNode {
id: node_id.clone(),
name: "Test Node".to_string(),
address: "localhost:8080".to_string(),
auth: AuthMethod::None,
options: ConnectionOptions::default(),
tags: vec![],
description: String::new(),
};
assert!(manager.add_node(node.clone()).is_ok());
assert!(manager.get_node(&node_id).is_some());
assert!(manager.remove_node(&node_id).is_ok());
assert!(manager.get_node(&node_id).is_none());
}
#[test]
fn test_list_nodes_by_tag() {
let mut manager = RemoteManager::new().unwrap();
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_micros();
let node1_id = format!("test-tag-node1-{}", timestamp);
let node2_id = format!("test-tag-node2-{}", timestamp);
let node1 = RemoteNode {
id: node1_id.clone(),
name: "Node 1".to_string(),
address: "localhost:8080".to_string(),
auth: AuthMethod::None,
options: ConnectionOptions::default(),
tags: vec!["prod".to_string()],
description: String::new(),
};
let node2 = RemoteNode {
id: node2_id.clone(),
name: "Node 2".to_string(),
address: "localhost:8081".to_string(),
auth: AuthMethod::None,
options: ConnectionOptions::default(),
tags: vec!["dev".to_string()],
description: String::new(),
};
let _ = manager.add_node(node1);
let _ = manager.add_node(node2);
let prod_nodes = manager.list_nodes_by_tag("prod");
assert!(prod_nodes.iter().any(|n| n.id == node1_id));
let _ = manager.remove_node(&node1_id);
let _ = manager.remove_node(&node2_id);
}
}