rustant_core/nodes/
linux.rs1use super::macos::LocalExecutor;
7use super::{
8 Node,
9 types::{Capability, NodeHealth, NodeId, NodeInfo, NodeResult, NodeTask, Platform},
10};
11use crate::error::{NodeError, RustantError};
12use async_trait::async_trait;
13use chrono::Utc;
14
15pub struct LinuxNode {
20 node_id: NodeId,
21 info: NodeInfo,
22 capabilities: Vec<Capability>,
23 executor: Box<dyn LocalExecutor>,
24 health: NodeHealth,
25}
26
27impl LinuxNode {
28 pub fn new(executor: Box<dyn LocalExecutor>) -> Self {
29 let hostname = std::env::var("HOSTNAME")
30 .or_else(|_| std::env::var("HOST"))
31 .unwrap_or_else(|_| "linux-local".to_string());
32 let node_id = NodeId::new(format!("linux-{}", hostname));
33 let info = NodeInfo {
34 node_id: node_id.clone(),
35 name: format!("Linux ({})", hostname),
36 platform: Platform::Linux,
37 hostname,
38 registered_at: Utc::now(),
39 os_version: None,
40 agent_version: env!("CARGO_PKG_VERSION").to_string(),
41 uptime_secs: 0,
42 };
43 Self {
44 node_id,
45 info,
46 capabilities: vec![
47 Capability::Shell,
48 Capability::FileSystem,
49 Capability::Screenshot,
50 Capability::Clipboard,
51 Capability::Notifications,
52 ],
53 executor,
54 health: NodeHealth::Healthy,
55 }
56 }
57}
58
59#[async_trait]
60impl Node for LinuxNode {
61 fn node_id(&self) -> &NodeId {
62 &self.node_id
63 }
64
65 fn info(&self) -> &NodeInfo {
66 &self.info
67 }
68
69 fn capabilities(&self) -> &[Capability] {
70 &self.capabilities
71 }
72
73 async fn execute(&self, task: NodeTask) -> Result<NodeResult, RustantError> {
74 if !self.capabilities.contains(&task.capability) {
75 return Err(RustantError::Node(NodeError::ExecutionFailed {
76 node_id: self.node_id.to_string(),
77 message: format!("Capability {} not supported on Linux", task.capability),
78 }));
79 }
80
81 let start = std::time::Instant::now();
82 let (output, exit_code) = self
83 .executor
84 .execute_command(&task.command, &task.args, task.timeout_secs)
85 .await
86 .map_err(|e| {
87 RustantError::Node(NodeError::ExecutionFailed {
88 node_id: self.node_id.to_string(),
89 message: e,
90 })
91 })?;
92 let duration_ms = start.elapsed().as_millis() as u64;
93
94 Ok(NodeResult {
95 task_id: task.task_id,
96 success: exit_code == 0,
97 output,
98 exit_code: Some(exit_code),
99 duration_ms,
100 })
101 }
102
103 fn health(&self) -> NodeHealth {
104 self.health
105 }
106
107 async fn heartbeat(&self) -> Result<NodeHealth, RustantError> {
108 match self
109 .executor
110 .execute_command("echo", &["ok".into()], 5)
111 .await
112 {
113 Ok((output, 0)) if output.contains("ok") => Ok(NodeHealth::Healthy),
114 Ok(_) => Ok(NodeHealth::Degraded),
115 Err(_) => Ok(NodeHealth::Unreachable),
116 }
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 struct MockExecutor {
125 output: String,
126 exit_code: i32,
127 }
128
129 impl MockExecutor {
130 fn ok(output: &str) -> Self {
131 Self {
132 output: output.to_string(),
133 exit_code: 0,
134 }
135 }
136
137 fn fail(message: &str) -> Self {
138 Self {
139 output: message.to_string(),
140 exit_code: 1,
141 }
142 }
143 }
144
145 #[async_trait]
146 impl LocalExecutor for MockExecutor {
147 async fn execute_command(
148 &self,
149 _cmd: &str,
150 _args: &[String],
151 _timeout: u64,
152 ) -> Result<(String, i32), String> {
153 Ok((self.output.clone(), self.exit_code))
154 }
155 }
156
157 #[test]
158 fn test_linux_node_capabilities() {
159 let node = LinuxNode::new(Box::new(MockExecutor::ok("ok")));
160 let caps = node.capabilities();
161 assert!(caps.contains(&Capability::Shell));
162 assert!(caps.contains(&Capability::FileSystem));
163 assert!(caps.contains(&Capability::Screenshot));
164 assert!(caps.contains(&Capability::Clipboard));
165 assert!(caps.contains(&Capability::Notifications));
166 assert!(!caps.contains(&Capability::AppleScript));
168 }
169
170 #[test]
171 fn test_linux_node_platform() {
172 let node = LinuxNode::new(Box::new(MockExecutor::ok("ok")));
173 assert_eq!(node.info().platform, Platform::Linux);
174 }
175
176 #[tokio::test]
177 async fn test_linux_execute_success() {
178 let node = LinuxNode::new(Box::new(MockExecutor::ok("file1\nfile2\n")));
179 let task = NodeTask::new(Capability::Shell, "ls");
180 let result = node.execute(task).await.unwrap();
181 assert!(result.success);
182 assert_eq!(result.output, "file1\nfile2\n");
183 assert_eq!(result.exit_code, Some(0));
184 }
185
186 #[tokio::test]
187 async fn test_linux_execute_failure() {
188 let node = LinuxNode::new(Box::new(MockExecutor::fail("command not found")));
189 let task = NodeTask::new(Capability::Shell, "bad-cmd");
190 let result = node.execute(task).await.unwrap();
191 assert!(!result.success);
192 assert_eq!(result.exit_code, Some(1));
193 }
194
195 #[tokio::test]
196 async fn test_linux_unsupported_capability() {
197 let node = LinuxNode::new(Box::new(MockExecutor::ok("ok")));
198 let task = NodeTask::new(Capability::AppleScript, "osascript");
199 let result = node.execute(task).await;
200 assert!(result.is_err());
201 }
202
203 #[tokio::test]
204 async fn test_linux_heartbeat() {
205 let node = LinuxNode::new(Box::new(MockExecutor::ok("ok")));
206 let health = node.heartbeat().await.unwrap();
207 assert_eq!(health, NodeHealth::Healthy);
208 }
209}