1use std::path::Path;
2use std::time::Duration;
3
4use crate::error::{Error, Result};
5use crate::node::daemon::forward::{LogForwardEnableRequest, LogForwardResult, LogForwardStatus};
6use crate::node::daemon::health::FleetHealth;
7use crate::node::process::detach;
8use crate::node::types::{
9 DaemonConfig, DaemonInfo, DaemonStartResult, DaemonStatus, DaemonStopResult, NodeStarted,
10 NodeStatusResult, NodeStopped, RemoveNodeResult, StartNodeResult, StopNodeResult,
11};
12
13pub async fn status(config: &DaemonConfig) -> Result<DaemonStatus> {
17 let port = match read_port_file(&config.port_file_path) {
18 Some(port) => port,
19 None => {
20 return Ok(DaemonStatus {
21 running: false,
22 pid: None,
23 port: None,
24 uptime_secs: None,
25 nodes_total: 0,
26 nodes_running: 0,
27 nodes_stopped: 0,
28 nodes_errored: 0,
29 });
30 }
31 };
32
33 let url = format!("http://127.0.0.1:{port}/api/v1/status");
34 match reqwest::get(&url).await {
35 Ok(resp) => resp
36 .json::<DaemonStatus>()
37 .await
38 .map_err(|e| Error::HttpRequest(e.to_string())),
39 Err(_) => Ok(DaemonStatus {
40 running: false,
41 pid: None,
42 port: Some(port),
43 uptime_secs: None,
44 nodes_total: 0,
45 nodes_running: 0,
46 nodes_stopped: 0,
47 nodes_errored: 0,
48 }),
49 }
50}
51
52pub async fn stop(config: &DaemonConfig) -> Result<DaemonStopResult> {
58 let pid = read_pid_file(&config.pid_file_path)?;
59
60 if !is_process_alive(pid) {
63 let _ = std::fs::remove_file(&config.pid_file_path);
65 let _ = std::fs::remove_file(&config.port_file_path);
66 return Ok(DaemonStopResult { pid });
67 }
68
69 if !validate_daemon_process(pid) {
70 let _ = std::fs::remove_file(&config.pid_file_path);
72 let _ = std::fs::remove_file(&config.port_file_path);
73 return Err(Error::DaemonStopFailed(format!(
74 "PID {pid} is alive but does not appear to be the ant daemon (possible PID reuse). \
75 Stale PID file removed."
76 )));
77 }
78
79 send_terminate(pid);
80
81 for _ in 0..50 {
83 tokio::time::sleep(Duration::from_millis(100)).await;
84 if !is_process_alive(pid) {
85 break;
86 }
87 }
88
89 if is_process_alive(pid) {
91 return Err(Error::DaemonStopFailed(format!(
92 "Daemon (PID {pid}) is still alive after 5 seconds"
93 )));
94 }
95
96 let _ = std::fs::remove_file(&config.pid_file_path);
98 let _ = std::fs::remove_file(&config.port_file_path);
99
100 Ok(DaemonStopResult { pid })
101}
102
103pub async fn start(config: &DaemonConfig) -> Result<DaemonStartResult> {
108 if let Some(pid) = check_running(&config.pid_file_path) {
110 let port = read_port_file(&config.port_file_path);
111 return Ok(DaemonStartResult {
112 already_running: true,
113 pid,
114 port,
115 });
116 }
117
118 let exe = std::env::current_exe()
120 .map_err(|e| Error::ProcessSpawn(format!("Failed to get current executable: {e}")))?;
121 let exe_str = exe
122 .to_str()
123 .ok_or_else(|| Error::ProcessSpawn("Executable path is not valid UTF-8".to_string()))?;
124
125 let args = daemon_run_args(config);
126 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
127 let pid = detach::spawn_detached(exe_str, &arg_refs)?;
128
129 let mut port = None;
131 for _ in 0..20 {
132 tokio::time::sleep(Duration::from_millis(100)).await;
133 if let Some(p) = read_port_file(&config.port_file_path) {
134 port = Some(p);
135 break;
136 }
137 }
138
139 Ok(DaemonStartResult {
140 already_running: false,
141 pid,
142 port,
143 })
144}
145
146pub async fn start_node(config: &DaemonConfig, node_id: u32) -> Result<NodeStarted> {
148 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
149
150 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}/start");
151 let resp = reqwest::Client::new()
152 .post(&url)
153 .send()
154 .await
155 .map_err(|e| Error::HttpRequest(e.to_string()))?;
156
157 if resp.status().is_success() {
158 resp.json::<NodeStarted>()
159 .await
160 .map_err(|e| Error::HttpRequest(e.to_string()))
161 } else {
162 let body = resp.text().await.unwrap_or_default();
163 Err(Error::HttpRequest(body))
164 }
165}
166
167pub async fn log_forward_status(config: &DaemonConfig) -> Result<LogForwardStatus> {
169 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
170
171 let url = format!("http://127.0.0.1:{port}/api/v1/logs/forward");
172 let resp = reqwest::get(&url)
173 .await
174 .map_err(|e| Error::HttpRequest(e.to_string()))?;
175
176 if resp.status().is_success() {
177 resp.json::<LogForwardStatus>()
178 .await
179 .map_err(|e| Error::HttpRequest(e.to_string()))
180 } else {
181 Err(Error::HttpRequest(resp.text().await.unwrap_or_default()))
182 }
183}
184
185pub async fn log_forward_enable(
187 config: &DaemonConfig,
188 request: &LogForwardEnableRequest,
189) -> Result<LogForwardResult> {
190 post_log_forward(config, "enable", Some(request)).await
191}
192
193pub async fn log_forward_disable(config: &DaemonConfig) -> Result<LogForwardResult> {
195 post_log_forward(config, "disable", None).await
196}
197
198async fn post_log_forward(
199 config: &DaemonConfig,
200 action: &str,
201 body: Option<&LogForwardEnableRequest>,
202) -> Result<LogForwardResult> {
203 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
204
205 let url = format!("http://127.0.0.1:{port}/api/v1/logs/forward/{action}");
206 let mut request = reqwest::Client::new().post(&url);
207 if let Some(body) = body {
208 request = request.json(body);
209 }
210
211 let resp = request
212 .send()
213 .await
214 .map_err(|e| Error::HttpRequest(e.to_string()))?;
215
216 if resp.status().is_success() {
217 resp.json::<LogForwardResult>()
218 .await
219 .map_err(|e| Error::HttpRequest(e.to_string()))
220 } else {
221 let body = resp.text().await.unwrap_or_default();
224 let message = serde_json::from_str::<serde_json::Value>(&body)
225 .ok()
226 .and_then(|value| {
227 value
228 .get("error")
229 .and_then(serde_json::Value::as_str)
230 .map(str::to_string)
231 })
232 .unwrap_or(body);
233 Err(Error::HttpRequest(message))
234 }
235}
236
237pub async fn start_all_nodes(config: &DaemonConfig) -> Result<StartNodeResult> {
239 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
240
241 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/start-all");
242 let resp = reqwest::Client::new()
243 .post(&url)
244 .send()
245 .await
246 .map_err(|e| Error::HttpRequest(e.to_string()))?;
247
248 if resp.status().is_success() {
249 resp.json::<StartNodeResult>()
250 .await
251 .map_err(|e| Error::HttpRequest(e.to_string()))
252 } else {
253 let body = resp.text().await.unwrap_or_default();
254 Err(Error::HttpRequest(body))
255 }
256}
257
258pub async fn stop_node(config: &DaemonConfig, node_id: u32) -> Result<NodeStopped> {
260 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
261
262 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}/stop");
263 let resp = reqwest::Client::new()
264 .post(&url)
265 .send()
266 .await
267 .map_err(|e| Error::HttpRequest(e.to_string()))?;
268
269 if resp.status().is_success() {
270 resp.json::<NodeStopped>()
271 .await
272 .map_err(|e| Error::HttpRequest(e.to_string()))
273 } else {
274 let body = resp.text().await.unwrap_or_default();
275 Err(Error::HttpRequest(body))
276 }
277}
278
279pub async fn dismiss_node(config: &DaemonConfig, node_id: u32) -> Result<RemoveNodeResult> {
284 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
285
286 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}");
287 let resp = reqwest::Client::new()
288 .delete(&url)
289 .send()
290 .await
291 .map_err(|e| Error::HttpRequest(e.to_string()))?;
292
293 if resp.status().is_success() {
294 resp.json::<RemoveNodeResult>()
295 .await
296 .map_err(|e| Error::HttpRequest(e.to_string()))
297 } else {
298 let body = resp.text().await.unwrap_or_default();
299 Err(Error::HttpRequest(body))
300 }
301}
302
303pub async fn fleet_health(config: &DaemonConfig) -> Result<FleetHealth> {
305 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
306
307 let url = format!("http://127.0.0.1:{port}/api/v1/health");
308 let resp = reqwest::get(&url)
309 .await
310 .map_err(|e| Error::HttpRequest(e.to_string()))?;
311
312 if resp.status().is_success() {
313 resp.json::<FleetHealth>()
314 .await
315 .map_err(|e| Error::HttpRequest(e.to_string()))
316 } else {
317 let body = resp.text().await.unwrap_or_default();
318 Err(Error::HttpRequest(body))
319 }
320}
321
322pub async fn node_status(config: &DaemonConfig) -> Result<NodeStatusResult> {
324 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
325
326 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/status");
327 let resp = reqwest::get(&url)
328 .await
329 .map_err(|e| Error::HttpRequest(e.to_string()))?;
330
331 if resp.status().is_success() {
332 resp.json::<NodeStatusResult>()
333 .await
334 .map_err(|e| Error::HttpRequest(e.to_string()))
335 } else {
336 let body = resp.text().await.unwrap_or_default();
337 Err(Error::HttpRequest(body))
338 }
339}
340
341pub async fn stop_all_nodes(config: &DaemonConfig) -> Result<StopNodeResult> {
343 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
344
345 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/stop-all");
346 let resp = reqwest::Client::new()
347 .post(&url)
348 .send()
349 .await
350 .map_err(|e| Error::HttpRequest(e.to_string()))?;
351
352 if resp.status().is_success() {
353 resp.json::<StopNodeResult>()
354 .await
355 .map_err(|e| Error::HttpRequest(e.to_string()))
356 } else {
357 let body = resp.text().await.unwrap_or_default();
358 Err(Error::HttpRequest(body))
359 }
360}
361
362pub fn info(config: &DaemonConfig) -> DaemonInfo {
366 let pid = std::fs::read_to_string(&config.pid_file_path)
367 .ok()
368 .and_then(|s| s.trim().parse::<u32>().ok());
369
370 let port = read_port_file(&config.port_file_path);
371
372 let running = pid.is_some_and(is_process_alive);
373
374 DaemonInfo {
375 running,
376 pid,
377 port,
378 api_base: port.map(|p| format!("http://127.0.0.1:{p}/api/v1")),
379 }
380}
381
382pub async fn run(config: DaemonConfig) -> Result<()> {
386 use crate::node::daemon::server;
387 use crate::node::registry::NodeRegistry;
388
389 let registry = NodeRegistry::load(&config.registry_path)?;
390 let shutdown = tokio_util::sync::CancellationToken::new();
391
392 let shutdown_clone = shutdown.clone();
393 tokio::spawn(async move {
394 tokio::signal::ctrl_c().await.ok();
395 shutdown_clone.cancel();
396 });
397
398 let _addr = server::start(config, registry, shutdown.clone()).await?;
399
400 shutdown.cancelled().await;
401 tokio::time::sleep(Duration::from_millis(100)).await;
403
404 Ok(())
405}
406
407#[cfg(unix)]
410fn validate_daemon_process(pid: u32) -> bool {
411 let cmdline_path = format!("/proc/{pid}/cmdline");
412 match std::fs::read(&cmdline_path) {
413 Ok(raw) => {
414 let args: Vec<String> = raw
419 .split(|&b| b == 0)
420 .filter(|s| !s.is_empty())
421 .map(|s| String::from_utf8_lossy(s).to_string())
422 .collect();
423 let exe_matches = args
424 .first()
425 .and_then(|exe| std::path::Path::new(exe).file_name())
426 .and_then(|name| name.to_str())
427 .is_some_and(|name| name == "ant" || name == "ant.exe");
428 let has_daemon_arg = args.iter().any(|a| a == "daemon");
429 exe_matches && has_daemon_arg
430 }
431 Err(_) => {
432 true
436 }
437 }
438}
439
440#[cfg(windows)]
441fn validate_daemon_process(pid: u32) -> bool {
442 use windows_sys::Win32::Foundation::CloseHandle;
443 use windows_sys::Win32::System::Threading::{
444 OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
445 };
446
447 unsafe {
448 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
449 if handle.is_null() {
450 return false;
451 }
452 let mut buf = [0u16; 1024];
453 let mut size = buf.len() as u32;
454 let success = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size);
455 CloseHandle(handle);
456
457 if success == 0 {
458 return false;
459 }
460 let path = String::from_utf16_lossy(&buf[..size as usize]);
461 std::path::Path::new(&path)
463 .file_stem()
464 .and_then(|s| s.to_str())
465 .is_some_and(|name| name == "ant")
466 }
467}
468
469fn daemon_run_args(config: &DaemonConfig) -> Vec<String> {
475 let defaults = DaemonConfig::default();
476 let mut args = vec!["node".to_string(), "daemon".to_string(), "run".to_string()];
477 if let Some(port) = config.port {
478 args.push("--port".to_string());
479 args.push(port.to_string());
480 }
481 if config.listen_addr != defaults.listen_addr {
482 args.push("--listen-addr".to_string());
483 args.push(config.listen_addr.to_string());
484 }
485 args
486}
487
488fn read_port_file(path: &Path) -> Option<u16> {
489 std::fs::read_to_string(path)
490 .ok()
491 .and_then(|s| s.trim().parse::<u16>().ok())
492}
493
494fn read_pid_file(path: &Path) -> Result<u32> {
495 let contents = std::fs::read_to_string(path).map_err(|_| Error::DaemonNotRunning)?;
496 contents
497 .trim()
498 .parse::<u32>()
499 .map_err(|_| Error::DaemonNotRunning)
500}
501
502fn check_running(pid_file: &Path) -> Option<u32> {
504 let pid = read_pid_file(pid_file).ok()?;
505 if is_process_alive(pid) {
506 Some(pid)
507 } else {
508 None
509 }
510}
511
512#[cfg(unix)]
513fn pid_to_i32(pid: u32) -> Option<i32> {
514 i32::try_from(pid).ok().filter(|&p| p > 0)
515}
516
517#[cfg(unix)]
518fn send_terminate(pid: u32) {
519 if let Some(pid) = pid_to_i32(pid) {
520 unsafe {
521 libc::kill(pid, libc::SIGTERM);
522 }
523 }
524}
525
526#[cfg(windows)]
527fn send_terminate(pid: u32) {
528 use windows_sys::Win32::Foundation::CloseHandle;
529 use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
530
531 unsafe {
532 let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
533 if !handle.is_null() {
534 TerminateProcess(handle, 1);
535 CloseHandle(handle);
536 }
537 }
538}
539
540#[cfg(unix)]
541fn is_process_alive(pid: u32) -> bool {
542 let Some(pid) = pid_to_i32(pid) else {
543 return false;
544 };
545 let ret = unsafe { libc::kill(pid, 0) };
546 if ret == 0 {
547 return true;
548 }
549 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
551}
552
553#[cfg(windows)]
554fn is_process_alive(pid: u32) -> bool {
555 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
556 use windows_sys::Win32::System::Threading::{
557 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
558 };
559
560 unsafe {
561 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
562 if handle.is_null() {
563 return false;
564 }
565 let mut exit_code: u32 = 0;
566 let success = GetExitCodeProcess(handle, &mut exit_code);
567 CloseHandle(handle);
568 success != 0 && exit_code == STILL_ACTIVE as u32
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::net::Ipv4Addr;
576
577 #[test]
578 fn run_args_default_config_has_no_overrides() {
579 let config = DaemonConfig::default();
580 let args = daemon_run_args(&config);
581 assert_eq!(args, vec!["node", "daemon", "run"]);
582 }
583
584 #[test]
585 fn run_args_forward_explicit_port() {
586 let config = DaemonConfig {
587 port: Some(8765),
588 ..DaemonConfig::default()
589 };
590 let args = daemon_run_args(&config);
591 assert_eq!(args, vec!["node", "daemon", "run", "--port", "8765"]);
592 }
593
594 #[test]
595 fn run_args_forward_explicit_listen_addr() {
596 let config = DaemonConfig {
597 listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
598 ..DaemonConfig::default()
599 };
600 let args = daemon_run_args(&config);
601 assert_eq!(
602 args,
603 vec!["node", "daemon", "run", "--listen-addr", "0.0.0.0"]
604 );
605 }
606
607 #[test]
608 fn run_args_forward_both_overrides() {
609 let config = DaemonConfig {
610 port: Some(8765),
611 listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
612 ..DaemonConfig::default()
613 };
614 let args = daemon_run_args(&config);
615 assert_eq!(
616 args,
617 vec![
618 "node",
619 "daemon",
620 "run",
621 "--port",
622 "8765",
623 "--listen-addr",
624 "0.0.0.0",
625 ]
626 );
627 }
628
629 #[test]
630 fn run_args_forward_explicit_zero_port() {
631 let config = DaemonConfig {
635 port: Some(0),
636 ..DaemonConfig::default()
637 };
638 let args = daemon_run_args(&config);
639 assert_eq!(args, vec!["node", "daemon", "run", "--port", "0"]);
640 }
641}