1use crate::config::RavenFabricConfig;
18use serde::{Deserialize, Serialize};
19use tracing::{info, warn};
20
21#[derive(Debug, Clone)]
26pub struct RavenFabricClient {
27 config: RavenFabricConfig,
29 http_client: reqwest::Client,
31}
32
33#[derive(Debug, Serialize)]
35struct ExecuteRequest {
36 command: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 target_host: Option<String>,
41 #[serde(default = "default_timeout")]
43 timeout_secs: u64,
44 agent_id: String,
46}
47
48#[allow(dead_code)]
49fn default_timeout() -> u64 {
50 30
51}
52
53#[derive(Debug, Deserialize)]
55#[allow(dead_code)]
56pub struct ExecuteResponse {
57 pub success: bool,
59 pub stdout: String,
61 pub stderr: String,
63 pub exit_code: i32,
65 pub duration_ms: u64,
67}
68
69#[derive(Debug, Deserialize)]
71#[allow(dead_code)]
72pub struct RemoteAgent {
73 pub id: String,
75 pub hostname: String,
77 pub status: String,
79 pub last_seen: String,
81 pub capabilities: Vec<String>,
83}
84
85impl RavenFabricClient {
86 pub fn new(config: &RavenFabricConfig) -> Option<Self> {
90 let endpoint = config.endpoint.as_ref()?;
91
92 let http_client = reqwest::Client::builder()
93 .timeout(std::time::Duration::from_secs(30))
94 .user_agent("RavenClaws/0.9.2")
95 .build()
96 .ok()?;
97
98 info!(
99 endpoint = %endpoint,
100 agent_id = ?config.agent_id,
101 "RavenFabric client initialized"
102 );
103
104 Some(Self {
105 config: config.clone(),
106 http_client,
107 })
108 }
109
110 pub fn endpoint(&self) -> Option<&str> {
112 self.config.endpoint.as_deref()
113 }
114
115 pub fn agent_id(&self) -> Option<&str> {
117 self.config.agent_id.as_deref()
118 }
119
120 pub fn is_enabled(&self) -> bool {
122 self.config.remote_exec
123 }
124
125 fn http_url(&self, path: &str) -> Result<String> {
128 let endpoint = self.config.endpoint.as_deref().ok_or_else(|| {
129 RavenFabricError::NotConfigured("No RavenFabric endpoint configured".to_string())
130 })?;
131 let url = format!("{}{}", endpoint.trim_end_matches('/'), path);
132 let url = url
134 .replacen("ws://", "http://", 1)
135 .replacen("wss://", "https://", 1);
136 Ok(url)
137 }
138
139 pub async fn health(&self) -> Result<bool> {
141 let url = self.http_url("/api/v1/health")?;
142
143 match self.http_client.get(&url).send().await {
144 Ok(response) => Ok(response.status().is_success()),
145 Err(e) => {
146 warn!(error = %e, url = %url, "RavenFabric health check failed");
147 Err(RavenFabricError::ConnectionFailed(e.to_string()))
148 }
149 }
150 }
151
152 pub async fn list_agents(&self) -> Result<Vec<RemoteAgent>> {
154 let url = self.http_url("/api/v1/agents")?;
155
156 let response = self
157 .http_client
158 .get(&url)
159 .send()
160 .await
161 .map_err(|e| RavenFabricError::ConnectionFailed(e.to_string()))?;
162
163 if !response.status().is_success() {
164 return Err(RavenFabricError::RequestFailed(format!(
165 "Failed to list agents: HTTP {}",
166 response.status()
167 )));
168 }
169
170 let agents: Vec<RemoteAgent> = response.json().await.map_err(|e| {
171 RavenFabricError::RequestFailed(format!("Failed to parse agents: {}", e))
172 })?;
173
174 info!(count = agents.len(), "RavenFabric agents listed");
175 Ok(agents)
176 }
177
178 pub async fn execute(
182 &self,
183 command: &str,
184 target_host: Option<&str>,
185 timeout_secs: u64,
186 ) -> Result<ExecuteResponse> {
187 let url = self.http_url("/api/v1/execute")?;
188
189 let request = ExecuteRequest {
190 command: command.to_string(),
191 target_host: target_host.map(|s| s.to_string()),
192 timeout_secs,
193 agent_id: self
194 .config
195 .agent_id
196 .clone()
197 .unwrap_or_else(|| "ravenclaws-default".to_string()),
198 };
199
200 info!(
201 command = %command,
202 target = ?target_host,
203 timeout = timeout_secs,
204 "RavenFabric execute request"
205 );
206
207 let response = self
208 .http_client
209 .post(&url)
210 .json(&request)
211 .send()
212 .await
213 .map_err(|e| RavenFabricError::ConnectionFailed(e.to_string()))?;
214
215 if !response.status().is_success() {
216 let status = response.status();
217 let body = response.text().await.unwrap_or_default();
218 return Err(RavenFabricError::RequestFailed(format!(
219 "Execute request failed: HTTP {} — {}",
220 status, body
221 )));
222 }
223
224 let result: ExecuteResponse = response.json().await.map_err(|e| {
225 RavenFabricError::RequestFailed(format!("Failed to parse response: {}", e))
226 })?;
227
228 info!(
229 success = result.success,
230 exit_code = result.exit_code,
231 duration_ms = result.duration_ms,
232 "RavenFabric execute completed"
233 );
234
235 Ok(result)
236 }
237
238 pub async fn broadcast(
240 &self,
241 command: &str,
242 timeout_secs: u64,
243 ) -> Result<Vec<(String, Result<ExecuteResponse>)>> {
244 let agents = self.list_agents().await?;
245 let mut results = Vec::new();
246
247 for agent in &agents {
248 let result = self
249 .execute(command, Some(&agent.hostname), timeout_secs)
250 .await;
251 results.push((agent.id.clone(), result));
252 }
253
254 info!(
255 command = %command,
256 agent_count = agents.len(),
257 "RavenFabric broadcast completed"
258 );
259
260 Ok(results)
261 }
262}
263
264#[derive(Debug, thiserror::Error)]
266pub enum RavenFabricError {
267 #[error("RavenFabric not configured: {0}")]
268 NotConfigured(String),
269
270 #[error("RavenFabric connection failed: {0}")]
271 ConnectionFailed(String),
272
273 #[error("RavenFabric request failed: {0}")]
274 RequestFailed(String),
275}
276
277pub type Result<T> = std::result::Result<T, RavenFabricError>;
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn test_ravenfabric_client_new_no_endpoint() {
286 let config = RavenFabricConfig {
287 endpoint: None,
288 agent_id: None,
289 remote_exec: true,
290 allowed_hosts: vec![],
291 };
292 let client = RavenFabricClient::new(&config);
293 assert!(client.is_none(), "Client should be None when no endpoint");
294 }
295
296 #[test]
297 fn test_ravenfabric_client_new_with_endpoint() {
298 let config = RavenFabricConfig {
299 endpoint: Some("http://localhost:8080".to_string()),
300 agent_id: Some("test-agent".to_string()),
301 remote_exec: true,
302 allowed_hosts: vec![],
303 };
304 let client = RavenFabricClient::new(&config);
305 assert!(
306 client.is_some(),
307 "Client should be Some when endpoint is set"
308 );
309 let client = client.unwrap();
310 assert_eq!(client.endpoint(), Some("http://localhost:8080"));
311 assert_eq!(client.agent_id(), Some("test-agent"));
312 assert!(client.is_enabled());
313 }
314
315 #[test]
316 fn test_ravenfabric_client_disabled() {
317 let config = RavenFabricConfig {
318 endpoint: Some("http://localhost:8080".to_string()),
319 agent_id: None,
320 remote_exec: false,
321 allowed_hosts: vec![],
322 };
323 let client = RavenFabricClient::new(&config);
324 assert!(client.is_some());
325 assert!(!client.unwrap().is_enabled());
326 }
327
328 #[test]
329 fn test_ravenfabric_error_display() {
330 let err = RavenFabricError::NotConfigured("no endpoint".to_string());
331 assert_eq!(
332 format!("{}", err),
333 "RavenFabric not configured: no endpoint"
334 );
335
336 let err = RavenFabricError::ConnectionFailed("timeout".to_string());
337 assert_eq!(format!("{}", err), "RavenFabric connection failed: timeout");
338
339 let err = RavenFabricError::RequestFailed("bad request".to_string());
340 assert_eq!(
341 format!("{}", err),
342 "RavenFabric request failed: bad request"
343 );
344 }
345
346 #[tokio::test]
347 async fn test_ravenfabric_health_no_endpoint() {
348 let config = RavenFabricConfig {
349 endpoint: None,
350 agent_id: None,
351 remote_exec: true,
352 allowed_hosts: vec![],
353 };
354 let client = RavenFabricClient::new(&config);
355 assert!(client.is_none());
356 }
357
358 #[tokio::test]
359 async fn test_ravenfabric_execute_no_endpoint() {
360 let config = RavenFabricConfig {
361 endpoint: None,
362 agent_id: None,
363 remote_exec: true,
364 allowed_hosts: vec![],
365 };
366 assert!(RavenFabricClient::new(&config).is_none());
368 }
369
370 #[tokio::test]
371 async fn test_ravenfabric_health_connection_refused() {
372 let config = RavenFabricConfig {
373 endpoint: Some("http://127.0.0.1:1".to_string()), agent_id: Some("test".to_string()),
375 remote_exec: true,
376 allowed_hosts: vec![],
377 };
378 let client = RavenFabricClient::new(&config).unwrap();
379 let result = client.health().await;
380 assert!(result.is_err());
381 match result.unwrap_err() {
382 RavenFabricError::ConnectionFailed(_) => {} other => panic!("Expected ConnectionFailed, got: {}", other),
384 }
385 }
386
387 #[tokio::test]
388 async fn test_ravenfabric_list_agents_connection_refused() {
389 let config = RavenFabricConfig {
390 endpoint: Some("http://127.0.0.1:1".to_string()),
391 agent_id: Some("test".to_string()),
392 remote_exec: true,
393 allowed_hosts: vec![],
394 };
395 let client = RavenFabricClient::new(&config).unwrap();
396 let result = client.list_agents().await;
397 assert!(result.is_err());
398 }
399
400 #[tokio::test]
401 async fn test_ravenfabric_execute_connection_refused() {
402 let config = RavenFabricConfig {
403 endpoint: Some("http://127.0.0.1:1".to_string()),
404 agent_id: Some("test".to_string()),
405 remote_exec: true,
406 allowed_hosts: vec![],
407 };
408 let client = RavenFabricClient::new(&config).unwrap();
409 let result = client.execute("echo hello", None, 10).await;
410 assert!(result.is_err());
411 }
412
413 #[test]
414 fn test_execute_request_serialization() {
415 let request = ExecuteRequest {
416 command: "echo hello".to_string(),
417 target_host: Some("agent-1".to_string()),
418 timeout_secs: 30,
419 agent_id: "ravenclaws-test".to_string(),
420 };
421 let json = serde_json::to_string(&request).unwrap();
422 assert!(json.contains("echo hello"));
423 assert!(json.contains("agent-1"));
424 assert!(json.contains("ravenclaws-test"));
425 assert!(json.contains("30"));
426 }
427
428 #[test]
429 fn test_execute_request_no_target() {
430 let request = ExecuteRequest {
431 command: "uptime".to_string(),
432 target_host: None,
433 timeout_secs: 10,
434 agent_id: "test".to_string(),
435 };
436 let json = serde_json::to_string(&request).unwrap();
437 assert!(json.contains("uptime"));
438 assert!(
439 !json.contains("target_host"),
440 "target_host should be skipped when None"
441 );
442 }
443
444 #[test]
445 fn test_remote_agent_deserialization() {
446 let json = r#"{
447 "id": "agent-1",
448 "hostname": "worker-01.example.com",
449 "status": "online",
450 "last_seen": "2026-06-18T12:00:00Z",
451 "capabilities": ["shell", "file", "docker"]
452 }"#;
453 let agent: RemoteAgent = serde_json::from_str(json).unwrap();
454 assert_eq!(agent.id, "agent-1");
455 assert_eq!(agent.hostname, "worker-01.example.com");
456 assert_eq!(agent.status, "online");
457 assert_eq!(agent.capabilities.len(), 3);
458 }
459
460 #[test]
461 fn test_execute_response_deserialization() {
462 let json = r#"{
463 "success": true,
464 "stdout": "hello world\n",
465 "stderr": "",
466 "exit_code": 0,
467 "duration_ms": 42
468 }"#;
469 let response: ExecuteResponse = serde_json::from_str(json).unwrap();
470 assert!(response.success);
471 assert_eq!(response.stdout, "hello world\n");
472 assert_eq!(response.exit_code, 0);
473 assert_eq!(response.duration_ms, 42);
474 }
475
476 #[test]
477 fn test_execute_response_failure() {
478 let json = r#"{
479 "success": false,
480 "stdout": "",
481 "stderr": "command not found",
482 "exit_code": 127,
483 "duration_ms": 5
484 }"#;
485 let response: ExecuteResponse = serde_json::from_str(json).unwrap();
486 assert!(!response.success);
487 assert_eq!(response.stderr, "command not found");
488 assert_eq!(response.exit_code, 127);
489 }
490}