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 AddNodeOpts, AddNodeResult, DaemonConfig, DaemonInfo, DaemonStartResult, DaemonStatus,
10 DaemonStopResult, NodeStarted, NodeStatusResult, NodeStopped, RemoveNodeResult, ResetResult,
11 StartNodeResult, StopNodeResult,
12};
13
14pub async fn status(config: &DaemonConfig) -> Result<DaemonStatus> {
18 let port = match read_port_file(&config.port_file_path) {
19 Some(port) => port,
20 None => {
21 return Ok(DaemonStatus {
22 running: false,
23 pid: None,
24 port: None,
25 uptime_secs: None,
26 nodes_total: 0,
27 nodes_running: 0,
28 nodes_stopped: 0,
29 nodes_errored: 0,
30 });
31 }
32 };
33
34 let url = format!("http://127.0.0.1:{port}/api/v1/status");
35 match reqwest::get(&url).await {
36 Ok(resp) => resp
37 .json::<DaemonStatus>()
38 .await
39 .map_err(|e| Error::HttpRequest(e.to_string())),
40 Err(_) => Ok(DaemonStatus {
41 running: false,
42 pid: None,
43 port: Some(port),
44 uptime_secs: None,
45 nodes_total: 0,
46 nodes_running: 0,
47 nodes_stopped: 0,
48 nodes_errored: 0,
49 }),
50 }
51}
52
53pub async fn stop(config: &DaemonConfig) -> Result<DaemonStopResult> {
59 let pid = read_pid_file(&config.pid_file_path)?;
60
61 if !is_process_alive(pid) {
64 let _ = std::fs::remove_file(&config.pid_file_path);
66 let _ = std::fs::remove_file(&config.port_file_path);
67 return Ok(DaemonStopResult { pid });
68 }
69
70 if !validate_daemon_process(pid) {
71 let _ = std::fs::remove_file(&config.pid_file_path);
73 let _ = std::fs::remove_file(&config.port_file_path);
74 return Err(Error::DaemonStopFailed(format!(
75 "PID {pid} is alive but does not appear to be the ant daemon (possible PID reuse). \
76 Stale PID file removed."
77 )));
78 }
79
80 send_terminate(pid);
81
82 for _ in 0..50 {
84 tokio::time::sleep(Duration::from_millis(100)).await;
85 if !is_process_alive(pid) {
86 break;
87 }
88 }
89
90 if is_process_alive(pid) {
92 return Err(Error::DaemonStopFailed(format!(
93 "Daemon (PID {pid}) is still alive after 5 seconds"
94 )));
95 }
96
97 let _ = std::fs::remove_file(&config.pid_file_path);
99 let _ = std::fs::remove_file(&config.port_file_path);
100
101 Ok(DaemonStopResult { pid })
102}
103
104pub async fn start(config: &DaemonConfig) -> Result<DaemonStartResult> {
109 if let Some(pid) = check_running(&config.pid_file_path) {
111 let port = read_port_file(&config.port_file_path);
112 return Ok(DaemonStartResult {
113 already_running: true,
114 pid,
115 port,
116 });
117 }
118
119 let exe = std::env::current_exe()
121 .map_err(|e| Error::ProcessSpawn(format!("Failed to get current executable: {e}")))?;
122 let exe_str = exe
123 .to_str()
124 .ok_or_else(|| Error::ProcessSpawn("Executable path is not valid UTF-8".to_string()))?;
125
126 let args = daemon_run_args(config);
127 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
128 let pid = detach::spawn_detached(exe_str, &arg_refs)?;
129
130 let mut port = None;
132 for _ in 0..20 {
133 tokio::time::sleep(Duration::from_millis(100)).await;
134 if let Some(p) = read_port_file(&config.port_file_path) {
135 port = Some(p);
136 break;
137 }
138 }
139
140 Ok(DaemonStartResult {
141 already_running: false,
142 pid,
143 port,
144 })
145}
146
147pub async fn start_node(config: &DaemonConfig, node_id: u32) -> Result<NodeStarted> {
149 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
150
151 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}/start");
152 let resp = reqwest::Client::new()
153 .post(&url)
154 .send()
155 .await
156 .map_err(|e| Error::HttpRequest(e.to_string()))?;
157
158 if resp.status().is_success() {
159 resp.json::<NodeStarted>()
160 .await
161 .map_err(|e| Error::HttpRequest(e.to_string()))
162 } else {
163 let body = resp.text().await.unwrap_or_default();
164 Err(Error::HttpRequest(body))
165 }
166}
167
168pub async fn log_forward_status(config: &DaemonConfig) -> Result<LogForwardStatus> {
170 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
171
172 let url = format!("http://127.0.0.1:{port}/api/v1/logs/forward");
173 let resp = reqwest::get(&url)
174 .await
175 .map_err(|e| Error::HttpRequest(e.to_string()))?;
176
177 if resp.status().is_success() {
178 resp.json::<LogForwardStatus>()
179 .await
180 .map_err(|e| Error::HttpRequest(e.to_string()))
181 } else {
182 Err(Error::HttpRequest(resp.text().await.unwrap_or_default()))
183 }
184}
185
186pub async fn log_forward_enable(
188 config: &DaemonConfig,
189 request: &LogForwardEnableRequest,
190) -> Result<LogForwardResult> {
191 post_log_forward(config, "enable", Some(request)).await
192}
193
194pub async fn log_forward_disable(config: &DaemonConfig) -> Result<LogForwardResult> {
196 post_log_forward(config, "disable", None).await
197}
198
199async fn post_log_forward(
200 config: &DaemonConfig,
201 action: &str,
202 body: Option<&LogForwardEnableRequest>,
203) -> Result<LogForwardResult> {
204 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
205
206 let url = format!("http://127.0.0.1:{port}/api/v1/logs/forward/{action}");
207 let mut request = reqwest::Client::new().post(&url);
208 if let Some(body) = body {
209 request = request.json(body);
210 }
211
212 let resp = request
213 .send()
214 .await
215 .map_err(|e| Error::HttpRequest(e.to_string()))?;
216
217 if resp.status().is_success() {
218 resp.json::<LogForwardResult>()
219 .await
220 .map_err(|e| Error::HttpRequest(e.to_string()))
221 } else {
222 let body = resp.text().await.unwrap_or_default();
225 let message = serde_json::from_str::<serde_json::Value>(&body)
226 .ok()
227 .and_then(|value| {
228 value
229 .get("error")
230 .and_then(serde_json::Value::as_str)
231 .map(str::to_string)
232 })
233 .unwrap_or(body);
234 Err(Error::HttpRequest(message))
235 }
236}
237
238pub async fn start_all_nodes(config: &DaemonConfig) -> Result<StartNodeResult> {
240 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
241
242 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/start-all");
243 let resp = reqwest::Client::new()
244 .post(&url)
245 .send()
246 .await
247 .map_err(|e| Error::HttpRequest(e.to_string()))?;
248
249 if resp.status().is_success() {
250 resp.json::<StartNodeResult>()
251 .await
252 .map_err(|e| Error::HttpRequest(e.to_string()))
253 } else {
254 let body = resp.text().await.unwrap_or_default();
255 Err(Error::HttpRequest(body))
256 }
257}
258
259pub async fn stop_node(config: &DaemonConfig, node_id: u32) -> Result<NodeStopped> {
261 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
262
263 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}/stop");
264 let resp = reqwest::Client::new()
265 .post(&url)
266 .send()
267 .await
268 .map_err(|e| Error::HttpRequest(e.to_string()))?;
269
270 if resp.status().is_success() {
271 resp.json::<NodeStopped>()
272 .await
273 .map_err(|e| Error::HttpRequest(e.to_string()))
274 } else {
275 let body = resp.text().await.unwrap_or_default();
276 Err(Error::HttpRequest(body))
277 }
278}
279
280pub async fn add_node(config: &DaemonConfig, opts: &AddNodeOpts) -> Result<AddNodeResult> {
282 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
283
284 let url = format!("http://127.0.0.1:{port}/api/v1/nodes");
285 let resp = reqwest::Client::new()
286 .post(&url)
287 .json(opts)
288 .send()
289 .await
290 .map_err(|e| Error::HttpRequest(e.to_string()))?;
291
292 if resp.status().is_success() {
293 resp.json::<AddNodeResult>()
294 .await
295 .map_err(|e| Error::HttpRequest(e.to_string()))
296 } else {
297 let body = resp.text().await.unwrap_or_default();
298 Err(Error::HttpRequest(body))
299 }
300}
301
302pub async fn reset(config: &DaemonConfig) -> Result<ResetResult> {
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/reset");
308 let resp = reqwest::Client::new()
309 .post(&url)
310 .send()
311 .await
312 .map_err(|e| Error::HttpRequest(e.to_string()))?;
313
314 if resp.status().is_success() {
315 resp.json::<ResetResult>()
316 .await
317 .map_err(|e| Error::HttpRequest(e.to_string()))
318 } else {
319 let body = resp.text().await.unwrap_or_default();
320 Err(Error::HttpRequest(body))
321 }
322}
323
324pub async fn dismiss_node(config: &DaemonConfig, node_id: u32) -> Result<RemoveNodeResult> {
329 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
330
331 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/{node_id}");
332 let resp = reqwest::Client::new()
333 .delete(&url)
334 .send()
335 .await
336 .map_err(|e| Error::HttpRequest(e.to_string()))?;
337
338 if resp.status().is_success() {
339 resp.json::<RemoveNodeResult>()
340 .await
341 .map_err(|e| Error::HttpRequest(e.to_string()))
342 } else {
343 let body = resp.text().await.unwrap_or_default();
344 Err(Error::HttpRequest(body))
345 }
346}
347
348pub async fn fleet_health(config: &DaemonConfig) -> Result<FleetHealth> {
350 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
351
352 let url = format!("http://127.0.0.1:{port}/api/v1/health");
353 let resp = reqwest::get(&url)
354 .await
355 .map_err(|e| Error::HttpRequest(e.to_string()))?;
356
357 if resp.status().is_success() {
358 resp.json::<FleetHealth>()
359 .await
360 .map_err(|e| Error::HttpRequest(e.to_string()))
361 } else {
362 let body = resp.text().await.unwrap_or_default();
363 Err(Error::HttpRequest(body))
364 }
365}
366
367pub async fn node_status(config: &DaemonConfig) -> Result<NodeStatusResult> {
369 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
370
371 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/status");
372 let resp = reqwest::get(&url)
373 .await
374 .map_err(|e| Error::HttpRequest(e.to_string()))?;
375
376 if resp.status().is_success() {
377 resp.json::<NodeStatusResult>()
378 .await
379 .map_err(|e| Error::HttpRequest(e.to_string()))
380 } else {
381 let body = resp.text().await.unwrap_or_default();
382 Err(Error::HttpRequest(body))
383 }
384}
385
386pub async fn resolve_node_id_by_name(config: &DaemonConfig, service_name: &str) -> Result<u32> {
392 let status = node_status(config).await?;
393 status
394 .nodes
395 .iter()
396 .find(|n| n.name == service_name)
397 .map(|n| n.node_id)
398 .ok_or_else(|| Error::NodeNotFoundByName(service_name.to_string()))
399}
400
401pub async fn stop_all_nodes(config: &DaemonConfig) -> Result<StopNodeResult> {
403 let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?;
404
405 let url = format!("http://127.0.0.1:{port}/api/v1/nodes/stop-all");
406 let resp = reqwest::Client::new()
407 .post(&url)
408 .send()
409 .await
410 .map_err(|e| Error::HttpRequest(e.to_string()))?;
411
412 if resp.status().is_success() {
413 resp.json::<StopNodeResult>()
414 .await
415 .map_err(|e| Error::HttpRequest(e.to_string()))
416 } else {
417 let body = resp.text().await.unwrap_or_default();
418 Err(Error::HttpRequest(body))
419 }
420}
421
422pub fn info(config: &DaemonConfig) -> DaemonInfo {
426 let pid = std::fs::read_to_string(&config.pid_file_path)
427 .ok()
428 .and_then(|s| s.trim().parse::<u32>().ok());
429
430 let port = read_port_file(&config.port_file_path);
431
432 let running = pid.is_some_and(is_process_alive);
433
434 DaemonInfo {
435 running,
436 pid,
437 port,
438 api_base: port.map(|p| format!("http://127.0.0.1:{p}/api/v1")),
439 }
440}
441
442pub async fn run(config: DaemonConfig) -> Result<()> {
446 use crate::node::daemon::server;
447 use crate::node::registry::NodeRegistry;
448
449 let registry = NodeRegistry::load(&config.registry_path)?;
450 let shutdown = tokio_util::sync::CancellationToken::new();
451
452 let shutdown_clone = shutdown.clone();
453 tokio::spawn(async move {
454 tokio::signal::ctrl_c().await.ok();
455 shutdown_clone.cancel();
456 });
457
458 let _addr = server::start(config, registry, shutdown.clone()).await?;
459
460 shutdown.cancelled().await;
461 tokio::time::sleep(Duration::from_millis(100)).await;
463
464 Ok(())
465}
466
467#[cfg(unix)]
470fn validate_daemon_process(pid: u32) -> bool {
471 let cmdline_path = format!("/proc/{pid}/cmdline");
472 match std::fs::read(&cmdline_path) {
473 Ok(raw) => {
474 let args: Vec<String> = raw
479 .split(|&b| b == 0)
480 .filter(|s| !s.is_empty())
481 .map(|s| String::from_utf8_lossy(s).to_string())
482 .collect();
483 let exe_matches = args
484 .first()
485 .and_then(|exe| std::path::Path::new(exe).file_name())
486 .and_then(|name| name.to_str())
487 .is_some_and(|name| name == "ant" || name == "ant.exe");
488 let has_daemon_arg = args.iter().any(|a| a == "daemon");
489 exe_matches && has_daemon_arg
490 }
491 Err(_) => {
492 true
496 }
497 }
498}
499
500#[cfg(windows)]
501fn validate_daemon_process(pid: u32) -> bool {
502 use windows_sys::Win32::Foundation::CloseHandle;
503 use windows_sys::Win32::System::Threading::{
504 OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
505 };
506
507 unsafe {
508 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
509 if handle.is_null() {
510 return false;
511 }
512 let mut buf = [0u16; 1024];
513 let mut size = buf.len() as u32;
514 let success = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size);
515 CloseHandle(handle);
516
517 if success == 0 {
518 return false;
519 }
520 let path = String::from_utf16_lossy(&buf[..size as usize]);
521 std::path::Path::new(&path)
523 .file_stem()
524 .and_then(|s| s.to_str())
525 .is_some_and(|name| name == "ant")
526 }
527}
528
529fn daemon_run_args(config: &DaemonConfig) -> Vec<String> {
535 let defaults = DaemonConfig::default();
536 let mut args = vec!["node".to_string(), "daemon".to_string(), "run".to_string()];
537 if let Some(port) = config.port {
538 args.push("--port".to_string());
539 args.push(port.to_string());
540 }
541 if config.listen_addr != defaults.listen_addr {
542 args.push("--listen-addr".to_string());
543 args.push(config.listen_addr.to_string());
544 }
545 if let Some(ref log_path) = config.log_path {
548 args.push("--log-path".to_string());
549 args.push(log_path.display().to_string());
550 }
551 args
552}
553
554fn read_port_file(path: &Path) -> Option<u16> {
555 std::fs::read_to_string(path)
556 .ok()
557 .and_then(|s| s.trim().parse::<u16>().ok())
558}
559
560fn read_pid_file(path: &Path) -> Result<u32> {
561 let contents = std::fs::read_to_string(path).map_err(|_| Error::DaemonNotRunning)?;
562 contents
563 .trim()
564 .parse::<u32>()
565 .map_err(|_| Error::DaemonNotRunning)
566}
567
568fn check_running(pid_file: &Path) -> Option<u32> {
570 let pid = read_pid_file(pid_file).ok()?;
571 if is_process_alive(pid) {
572 Some(pid)
573 } else {
574 None
575 }
576}
577
578#[cfg(unix)]
579fn pid_to_i32(pid: u32) -> Option<i32> {
580 i32::try_from(pid).ok().filter(|&p| p > 0)
581}
582
583#[cfg(unix)]
584fn send_terminate(pid: u32) {
585 if let Some(pid) = pid_to_i32(pid) {
586 unsafe {
587 libc::kill(pid, libc::SIGTERM);
588 }
589 }
590}
591
592#[cfg(windows)]
593fn send_terminate(pid: u32) {
594 use windows_sys::Win32::Foundation::CloseHandle;
595 use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
596
597 unsafe {
598 let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
599 if !handle.is_null() {
600 TerminateProcess(handle, 1);
601 CloseHandle(handle);
602 }
603 }
604}
605
606#[cfg(unix)]
607fn is_process_alive(pid: u32) -> bool {
608 let Some(pid) = pid_to_i32(pid) else {
609 return false;
610 };
611 let ret = unsafe { libc::kill(pid, 0) };
612 if ret == 0 {
613 return true;
614 }
615 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
617}
618
619#[cfg(windows)]
620fn is_process_alive(pid: u32) -> bool {
621 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
622 use windows_sys::Win32::System::Threading::{
623 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
624 };
625
626 unsafe {
627 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
628 if handle.is_null() {
629 return false;
630 }
631 let mut exit_code: u32 = 0;
632 let success = GetExitCodeProcess(handle, &mut exit_code);
633 CloseHandle(handle);
634 success != 0 && exit_code == STILL_ACTIVE as u32
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use std::net::Ipv4Addr;
642
643 #[test]
644 fn run_args_default_config_has_no_overrides() {
645 let config = DaemonConfig::default();
646 let args = daemon_run_args(&config);
647 assert_eq!(args, vec!["node", "daemon", "run"]);
648 }
649
650 #[test]
651 fn run_args_forward_explicit_port() {
652 let config = DaemonConfig {
653 port: Some(8765),
654 ..DaemonConfig::default()
655 };
656 let args = daemon_run_args(&config);
657 assert_eq!(args, vec!["node", "daemon", "run", "--port", "8765"]);
658 }
659
660 #[test]
663 fn run_args_forward_the_log_path() {
664 let config = DaemonConfig {
665 log_path: Some(std::path::PathBuf::from("/var/log/ant/daemon.log")),
666 ..DaemonConfig::default()
667 };
668 let args = daemon_run_args(&config);
669 assert_eq!(
670 args,
671 vec![
672 "node",
673 "daemon",
674 "run",
675 "--log-path",
676 "/var/log/ant/daemon.log"
677 ]
678 );
679 }
680
681 #[test]
682 fn run_args_omit_the_log_path_by_default() {
683 let args = daemon_run_args(&DaemonConfig::default());
684 assert!(
685 !args.contains(&"--log-path".to_string()),
686 "daemon logging must stay opt-in"
687 );
688 }
689
690 #[test]
691 fn run_args_forward_explicit_listen_addr() {
692 let config = DaemonConfig {
693 listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
694 ..DaemonConfig::default()
695 };
696 let args = daemon_run_args(&config);
697 assert_eq!(
698 args,
699 vec!["node", "daemon", "run", "--listen-addr", "0.0.0.0"]
700 );
701 }
702
703 #[test]
704 fn run_args_forward_both_overrides() {
705 let config = DaemonConfig {
706 port: Some(8765),
707 listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
708 ..DaemonConfig::default()
709 };
710 let args = daemon_run_args(&config);
711 assert_eq!(
712 args,
713 vec![
714 "node",
715 "daemon",
716 "run",
717 "--port",
718 "8765",
719 "--listen-addr",
720 "0.0.0.0",
721 ]
722 );
723 }
724
725 #[test]
726 fn run_args_forward_explicit_zero_port() {
727 let config = DaemonConfig {
731 port: Some(0),
732 ..DaemonConfig::default()
733 };
734 let args = daemon_run_args(&config);
735 assert_eq!(args, vec!["node", "daemon", "run", "--port", "0"]);
736 }
737}