1mod render;
6
7use std::collections::BTreeMap;
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::time::Duration;
13
14use crate::node_log;
15use crate::windows_process::{
16 free_console, hide_console, ignore_console_close_events,
17 set_creation_flags, CREATE_NO_WINDOW,
18};
19
20pub use render::{
21 render_launchd_plist, render_schtasks_xml, render_systemd_unit, LaunchPlist, SchtasksXml,
22 SystemdUnit,
23};
24
25const ENV_PREFIXES: &[&str] = &["GATE4AGENT_", "HATCHERY_", "RUST_LOG"];
27
28#[derive(Clone, Debug)]
29pub struct ServiceSpec {
30 pub system: bool,
31 pub exe: PathBuf,
32 pub args: Vec<String>,
33 pub env: Vec<(String, String)>,
34 pub enroll_state: Option<PathBuf>,
35}
36
37#[derive(Clone, Debug)]
38pub struct ServiceStatus {
39 pub installed: bool,
40 pub running: bool,
41 pub scope: &'static str,
42 pub unit_name: String,
43 pub detail: String,
44 pub node_health: String,
46}
47
48impl ServiceSpec {
49 pub fn from_install_args(system: bool, node_args: &[String]) -> Result<Self, String> {
50 let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
51 let enroll_state = extract_enroll_state(node_args);
52 let env = capture_env();
53 Ok(Self {
54 system,
55 exe,
56 args: node_args.to_vec(),
57 env,
58 enroll_state,
59 })
60 }
61
62 pub fn unit_name(&self) -> String {
63 match &self.enroll_state {
64 Some(p) => {
65 let key = p.to_string_lossy();
66 let digest = short_digest(&key);
67 format!("gate4agent-node-{digest}")
68 }
69 None => "gate4agent-node".to_owned(),
70 }
71 }
72
73 pub fn launchd_label(&self) -> String {
74 format!("com.gate4agent.node.{}", self.unit_name())
75 }
76}
77
78fn extract_enroll_state(args: &[String]) -> Option<PathBuf> {
79 args.windows(2)
80 .find(|w| w[0] == "--enroll-state")
81 .map(|w| PathBuf::from(&w[1]))
82}
83
84fn capture_env() -> Vec<(String, String)> {
85 let mut out = BTreeMap::new();
86 for (k, v) in std::env::vars() {
87 if ENV_PREFIXES.iter().any(|p| k.starts_with(p)) || k == "RUST_LOG" {
88 out.insert(k, v);
89 }
90 }
91 out.into_iter().collect()
92}
93
94fn windows_task_exe(exe: &Path) -> PathBuf {
95 #[cfg(windows)]
96 {
97 let w = exe.with_file_name("gate4agent-nodew.exe");
98 if w.is_file() {
99 return w;
100 }
101 }
102 let _ = exe;
103 exe.to_path_buf()
104}
105
106fn short_digest(s: &str) -> String {
107 use sha2::{Digest, Sha256};
108 let h = Sha256::digest(s.as_bytes());
109 hex::encode(&h[..6])
110}
111
112fn home_dir() -> Result<PathBuf, String> {
113 std::env::var_os("HOME")
114 .or_else(|| std::env::var_os("USERPROFILE"))
115 .map(PathBuf::from)
116 .ok_or_else(|| "HOME/USERPROFILE is unset".to_owned())
117}
118
119const NOT_SUPPORTED: &str = "service install/uninstall/status/restart: not supported yet on Windows (use gate4agent-nodew)";
121
122pub fn install(spec: &ServiceSpec) -> Result<String, String> {
123 require_admin_if_system(spec.system)?;
124 #[cfg(target_os = "linux")]
125 {
126 return install_linux(spec);
127 }
128 #[cfg(target_os = "macos")]
129 {
130 return install_macos(spec);
131 }
132 #[cfg(windows)]
133 {
134 return Err(NOT_SUPPORTED.into());
135 }
136 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
137 {
138 let _ = spec;
139 Err("service install is unsupported on this OS".into())
140 }
141}
142
143pub fn uninstall(system: bool) -> Result<String, String> {
144 require_admin_if_system(system)?;
145 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
147 #[cfg(target_os = "linux")]
148 {
149 return uninstall_linux(system, &name);
150 }
151 #[cfg(target_os = "macos")]
152 {
153 return uninstall_macos(system, &name);
154 }
155 #[cfg(windows)]
156 {
157 return Err(NOT_SUPPORTED.into());
158 }
159 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
160 {
161 let _ = (system, name);
162 Err("service uninstall is unsupported on this OS".into())
163 }
164}
165
166pub fn status(system: bool) -> Result<ServiceStatus, String> {
167 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
168 #[cfg(target_os = "linux")]
169 {
170 return status_linux(system, &name);
171 }
172 #[cfg(target_os = "macos")]
173 {
174 return status_macos(system, &name);
175 }
176 #[cfg(windows)]
177 {
178 return Err(NOT_SUPPORTED.into());
179 }
180 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
181 {
182 let _ = (system, name);
183 Err("service-status is unsupported on this OS".into())
184 }
185}
186
187pub fn restart(system: bool) -> Result<String, String> {
188 require_admin_if_system(system)?;
189 let name = saved_unit_name(system).unwrap_or_else(|| "gate4agent-node".into());
190 #[cfg(target_os = "linux")]
191 {
192 return restart_linux(system, &name);
193 }
194 #[cfg(target_os = "macos")]
195 {
196 return restart_macos(system, &name);
197 }
198 #[cfg(windows)]
199 {
200 return Err(NOT_SUPPORTED.into());
201 }
202 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
203 {
204 let _ = (system, name);
205 Err("service-restart is unsupported on this OS".into())
206 }
207}
208
209fn require_admin_if_system(system: bool) -> Result<(), String> {
210 if !system {
211 return Ok(());
212 }
213 if is_elevated() {
214 Ok(())
215 } else {
216 Err("--system requires administrator / root privileges".into())
217 }
218}
219
220#[cfg(windows)]
221fn is_elevated_windows() -> bool {
222 false
223}
224
225fn is_elevated() -> bool {
226 #[cfg(unix)]
227 {
228 unsafe { libc::geteuid() == 0 }
229 }
230 #[cfg(windows)]
231 {
232 is_elevated_windows()
233 }
234 #[cfg(all(not(unix), not(windows)))]
235 {
236 false
237 }
238}
239
240fn marker_path(system: bool) -> Result<PathBuf, String> {
241 let base = if system {
242 #[cfg(unix)]
243 {
244 PathBuf::from("/var/lib/gate4agent-node")
245 }
246 #[cfg(not(unix))]
247 {
248 std::env::var_os("ProgramData")
249 .map(PathBuf::from)
250 .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
251 .join("gate4agent-node")
252 }
253 } else {
254 home_dir()?.join(".local").join("state").join("gate4agent-node")
255 };
256 Ok(base.join("service-unit-name"))
257}
258
259fn save_unit_name(system: bool, name: &str) -> Result<(), String> {
260 let path = marker_path(system)?;
261 if let Some(parent) = path.parent() {
262 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
263 }
264 fs::write(&path, name).map_err(|e| format!("write {}: {e}", path.display()))
265}
266
267fn saved_unit_name(system: bool) -> Option<String> {
268 let path = marker_path(system).ok()?;
269 fs::read_to_string(path).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
270}
271
272fn clear_unit_name(system: bool) {
273 if let Ok(path) = marker_path(system) {
274 let _ = fs::remove_file(path);
275 }
276}
277
278#[cfg(target_os = "linux")]
281fn unit_dir(system: bool) -> Result<PathBuf, String> {
282 if system {
283 Ok(PathBuf::from("/etc/systemd/system"))
284 } else {
285 Ok(home_dir()?.join(".config").join("systemd").join("user"))
286 }
287}
288
289#[cfg(target_os = "linux")]
290fn install_linux(spec: &ServiceSpec) -> Result<String, String> {
291 let name = spec.unit_name();
292 let dir = unit_dir(spec.system)?;
293 fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
294 let path = dir.join(format!("{name}.service"));
295 let body = render_systemd_unit(&SystemdUnit {
297 description: "gate4agent-node",
298 exe: &spec.exe.to_string_lossy(),
299 args: &spec.args,
300 env: &spec.env,
301 supervised: false,
302 });
303 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
304 save_unit_name(spec.system, &name)?;
305
306 let mut notes = Vec::new();
307 if !spec.system {
308 match Command::new("loginctl")
309 .args(["enable-linger"])
310 .status()
311 {
312 Ok(s) if s.success() => notes.push("linger enabled".into()),
313 Ok(_) | Err(_) => notes.push(
314 "warning: loginctl enable-linger failed or unavailable; user units may stop at logout"
315 .into(),
316 ),
317 }
318 }
319 match run_ok(&mut systemctl_cmd(spec.system).args(["daemon-reload"]))
320 .and_then(|_| run_ok(&mut systemctl_cmd(spec.system).args(["enable", "--now", &name])))
321 {
322 Ok(()) => notes.push(format!("installed and started {name} ({})", path.display())),
323 Err(e) => notes.push(format!(
324 "wrote {} but systemctl failed ({e}); start later when the user bus is up",
325 path.display()
326 )),
327 }
328 Ok(notes.join("; "))
329}
330
331#[cfg(target_os = "linux")]
332fn uninstall_linux(system: bool, name: &str) -> Result<String, String> {
333 let _ = systemctl_cmd(system).args(["disable", "--now", name]).status();
334 let path = unit_dir(system)?.join(format!("{name}.service"));
335 let _ = fs::remove_file(&path);
336 let _ = systemctl_cmd(system).args(["daemon-reload"]).status();
337 clear_unit_name(system);
338 Ok(format!("removed {name}"))
339}
340
341#[cfg(target_os = "linux")]
342fn status_linux(system: bool, name: &str) -> Result<ServiceStatus, String> {
343 let path = unit_dir(system)?.join(format!("{name}.service"));
344 let installed = path.is_file();
345 let out = systemctl_cmd(system).args(["is-active", name]).output();
346 let (running, detail) = match out {
347 Ok(o) => {
348 let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
349 (s == "active", s)
350 }
351 Err(e) => (false, e.to_string()),
352 };
353 Ok(ServiceStatus {
354 installed,
355 running,
356 scope: if system { "system" } else { "user" },
357 unit_name: name.to_owned(),
358 detail,
359 node_health: probe_node_health(),
360 })
361}
362
363#[cfg(target_os = "linux")]
364fn restart_linux(system: bool, name: &str) -> Result<String, String> {
365 run_ok(&mut systemctl_cmd(system).args(["restart", name]))?;
366 Ok(format!("restarted {name}"))
367}
368
369#[cfg(target_os = "linux")]
370fn systemctl_cmd(system: bool) -> Command {
371 let mut c = Command::new("systemctl");
372 if !system {
373 c.arg("--user");
374 }
375 c
376}
377
378#[cfg(target_os = "macos")]
381fn plist_path(system: bool, label: &str) -> Result<PathBuf, String> {
382 if system {
383 Ok(PathBuf::from("/Library/LaunchDaemons").join(format!("{label}.plist")))
384 } else {
385 Ok(home_dir()?
386 .join("Library")
387 .join("LaunchAgents")
388 .join(format!("{label}.plist")))
389 }
390}
391
392#[cfg(target_os = "macos")]
393fn install_macos(spec: &ServiceSpec) -> Result<String, String> {
394 let label = spec.launchd_label();
395 let path = plist_path(spec.system, &label)?;
396 if let Some(parent) = path.parent() {
397 fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
398 }
399 let body = render_launchd_plist(&LaunchPlist {
400 label: &label,
401 exe: &spec.exe.to_string_lossy(),
402 args: &spec.args,
403 env: &spec.env,
404 supervised: false, });
406 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
407 save_unit_name(spec.system, &spec.unit_name())?;
408 let domain = if spec.system {
409 "system".to_owned()
410 } else {
411 format!("gui/{}", unsafe { libc::getuid() })
412 };
413 let _ = Command::new("launchctl")
414 .args(["bootout", &domain, &path.to_string_lossy()])
415 .status();
416 run_ok(
417 &mut Command::new("launchctl").args(["bootstrap", &domain, &path.to_string_lossy()]),
418 )?;
419 let _ = Command::new("launchctl")
420 .args(["enable", &format!("{domain}/{label}")])
421 .status();
422 Ok(format!("installed {label} ({})", path.display()))
423}
424
425#[cfg(target_os = "macos")]
426fn uninstall_macos(system: bool, name: &str) -> Result<String, String> {
427 let label = format!("com.gate4agent.node.{name}");
428 let path = plist_path(system, &label)?;
429 let domain = if system {
430 "system".to_owned()
431 } else {
432 format!("gui/{}", unsafe { libc::getuid() })
433 };
434 let _ = Command::new("launchctl")
435 .args(["bootout", &domain, &path.to_string_lossy()])
436 .status();
437 let _ = fs::remove_file(&path);
438 clear_unit_name(system);
439 Ok(format!("removed {label}"))
440}
441
442#[cfg(target_os = "macos")]
443fn status_macos(system: bool, name: &str) -> Result<ServiceStatus, String> {
444 let label = format!("com.gate4agent.node.{name}");
445 let path = plist_path(system, &label)?;
446 let installed = path.is_file();
447 let out = Command::new("launchctl")
448 .args(["print", &format!("system/{label}")])
449 .output();
450 let running = match out {
452 Ok(o) if o.status.success() => true,
453 _ => Command::new("launchctl")
454 .args(["list", &label])
455 .status()
456 .map(|s| s.success())
457 .unwrap_or(false),
458 };
459 Ok(ServiceStatus {
460 installed,
461 running,
462 scope: if system { "system" } else { "user" },
463 unit_name: name.to_owned(),
464 detail: label,
465 node_health: probe_node_health(),
466 })
467}
468
469#[cfg(target_os = "macos")]
470fn restart_macos(system: bool, name: &str) -> Result<String, String> {
471 let label = format!("com.gate4agent.node.{name}");
472 let path = plist_path(system, &label)?;
473 let domain = if system {
474 "system".to_owned()
475 } else {
476 format!("gui/{}", unsafe { libc::getuid() })
477 };
478 let _ = Command::new("launchctl")
479 .args(["kickstart", "-k", &format!("{domain}/{label}")])
480 .status();
481 let _ = path;
482 Ok(format!("restarted {label}"))
483}
484
485fn probe_node_health() -> String {
489 let addr = std::env::var("GATE4AGENT_HEALTH_URL")
490 .or_else(|_| {
491 std::env::var("GATE4AGENT_API_LISTEN").map(|a| format!("http://{a}/health"))
492 })
493 .unwrap_or_else(|_| "http://127.0.0.1:18330/health".into());
494 let hostport = addr
495 .trim_start_matches("http://")
496 .trim_start_matches("https://")
497 .split('/')
498 .next()
499 .unwrap_or("127.0.0.1:18330");
500 use std::io::{Read, Write};
501 use std::net::ToSocketAddrs;
502 let sock = match hostport.to_socket_addrs() {
503 Ok(mut i) => match i.next() {
504 Some(a) => a,
505 None => return "bad_addr".into(),
506 },
507 Err(_) => return "bad_addr".into(),
508 };
509 let mut stream = match std::net::TcpStream::connect_timeout(&sock, Duration::from_secs(2)) {
510 Ok(s) => s,
511 Err(_) => return "down".into(),
512 };
513 let req = format!("GET /health HTTP/1.1
514Host: {hostport}
515Connection: close
516
517");
518 if stream.write_all(req.as_bytes()).is_err() {
519 return "unreachable".into();
520 }
521 let mut buf = vec![0u8; 4096];
522 let n = stream.read(&mut buf).unwrap_or(0);
523 let body = String::from_utf8_lossy(&buf[..n]);
524 if body.contains("\"ok\":true") || body.contains("\"ok\": true") {
525 "ok".into()
526 } else if n == 0 {
527 "no_response".into()
528 } else {
529 "responded_not_ok".into()
530 }
531}
532
533fn run_ok(cmd: &mut Command) -> Result<(), String> {
534 hide_console(cmd);
535 let out = cmd.output().map_err(|e| e.to_string())?;
536 if out.status.success() {
537 Ok(())
538 } else {
539 let stdout = String::from_utf8_lossy(&out.stdout);
540 let stderr = String::from_utf8_lossy(&out.stderr);
541 let combined = format!("{stdout} {stderr}").to_ascii_lowercase();
542 let hint = if combined.contains("access is denied") || combined.contains("access denied") {
543 " (Access denied: for a user-level task the XML must include your UserId/SID; try reinstalling this build, or run from an elevated prompt for --system)"
544 } else {
545 ""
546 };
547 Err(format!(
548 "command failed{hint}: stdout={stdout} stderr={stderr}"
549 ))
550 }
551}
552
553pub struct InstanceLock {
557 _file: File,
558 path: PathBuf,
559}
560
561pub fn acquire_instance_lock(enroll_state: &Path) -> Result<InstanceLock, String> {
562 fs::create_dir_all(enroll_state).map_err(|e| e.to_string())?;
563 let path = enroll_state.join("node.instance.lock");
564 #[cfg(windows)]
565 let file = {
566 use std::os::windows::fs::OpenOptionsExt;
567 OpenOptions::new()
568 .create(true)
569 .write(true)
570 .share_mode(0) .open(&path)
572 .map_err(|e| {
573 format!(
574 "another gate4agent-node already holds {} ({e})",
575 path.display()
576 )
577 })?
578 };
579 #[cfg(not(windows))]
580 let file = OpenOptions::new()
581 .create(true)
582 .write(true)
583 .open(&path)
584 .map_err(|e| format!("open {}: {e}", path.display()))?;
585 #[cfg(unix)]
586 {
587 use std::os::unix::io::AsRawFd;
588 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
589 if rc != 0 {
590 return Err(format!(
591 "another gate4agent-node already holds {}",
592 path.display()
593 ));
594 }
595 }
596 Ok(InstanceLock { _file: file, path })
597}
598
599pub fn install_panic_hook() {
600 let prev = std::panic::take_hook();
601 std::panic::set_hook(Box::new(move |info| {
602 let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
603 (*s).to_owned()
604 } else if let Some(s) = info.payload().downcast_ref::<String>() {
605 s.clone()
606 } else {
607 "panic".into()
608 };
609 let loc = info
610 .location()
611 .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
612 .unwrap_or_else(|| "unknown".into());
613 let _ = node_log::init_from_env();
614 node_log::error(
615 "process_panic",
616 &[("message", msg), ("location", loc)],
617 );
618 prev(info);
619 }));
620}
621
622pub fn run_supervised(child_args: &[String]) -> ! {
628 apply_sidecar_env(child_args);
629 ensure_node_log_from_args(child_args);
630 let _ = node_log::init_from_env();
631 install_panic_hook();
632 free_console();
633 ignore_console_close_events();
634 let exe = match std::env::current_exe() {
635 Ok(p) => p,
636 Err(e) => {
637 node_log::error("process_exit", &[("reason", format!("current_exe: {e}"))]);
638 std::process::exit(1);
639 }
640 };
641 let log_dir = child_log_dir(child_args);
642 let mut n: u64 = 0;
643 let mut backoff = Duration::from_secs(1);
644 loop {
645 n = n.saturating_add(1);
646 let stdout_path = log_dir.join("node.stdout.log");
647 let stderr_path = log_dir.join("node.stderr.log");
648 rotate_log_file(&stdout_path, 2_000_000);
649 rotate_log_file(&stderr_path, 2_000_000);
650 let mut cmd = Command::new(&exe);
651 cmd.args(child_args).stdin(Stdio::null());
652 match (OpenOptions::new().create(true).append(true).open(&stdout_path),
653 OpenOptions::new().create(true).append(true).open(&stderr_path))
654 {
655 (Ok(out), Ok(err)) => {
656 cmd.stdout(Stdio::from(out)).stderr(Stdio::from(err));
657 }
658 _ => {
659 #[cfg(windows)]
661 {
662 cmd.stdout(Stdio::null()).stderr(Stdio::null());
663 }
664 #[cfg(not(windows))]
665 {
666 cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
667 }
668 }
669 }
670 #[cfg(windows)]
671 {
672 set_creation_flags(&mut cmd, CREATE_NO_WINDOW);
675 }
676 cmd.env("GATE4AGENT_SUPERVISED_CHILD", "1");
677 for (k, v) in load_sidecar_env(child_args) {
679 cmd.env(k, v);
680 }
681 node_log::info(
682 "supervised_start",
683 &[
684 ("n", n.to_string()),
685 ("args_len", child_args.len().to_string()),
686 ("stderr_log", stderr_path.display().to_string()),
687 ],
688 );
689 let status = match cmd.status() {
690 Ok(s) => s,
691 Err(e) => {
692 node_log::error(
693 "process_exit",
694 &[
695 ("reason", format!("spawn: {e}")),
696 ("code", "spawn_error".into()),
697 ("supervised_n", n.to_string()),
698 ],
699 );
700 node_log::warn(
701 "supervised_restart",
702 &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
703 );
704 std::thread::sleep(backoff);
705 backoff = (backoff * 2).min(Duration::from_secs(30));
706 continue;
707 }
708 };
709 let code = status
710 .code()
711 .map(|c| c.to_string())
712 .unwrap_or_else(|| "signal".into());
713 let stderr_tail = read_tail(&stderr_path, 4_000);
714 node_log::warn(
715 "process_exit",
716 &[
717 ("reason", "child_exit".into()),
718 ("code", code.clone()),
719 ("supervised_n", n.to_string()),
720 ("stderr_tail", stderr_tail),
721 ],
722 );
723 node_log::warn(
724 "supervised_restart",
725 &[("n", n.to_string()), ("backoff_secs", backoff.as_secs().to_string())],
726 );
727 std::thread::sleep(backoff);
728 backoff = (backoff * 2).min(Duration::from_secs(30));
729 if status.success() {
730 backoff = Duration::from_secs(1);
731 }
732 }
733}
734
735fn child_log_dir(child_args: &[String]) -> PathBuf {
736 if let Some(p) = extract_enroll_state(child_args) {
737 let _ = fs::create_dir_all(&p);
738 return p;
739 }
740 std::env::temp_dir().join("gate4agent-node-supervised")
741}
742
743fn ensure_node_log_from_args(child_args: &[String]) {
744 if std::env::var_os(crate::node_log::env_name()).is_some() {
745 return;
746 }
747 if let Some(dir) = extract_enroll_state(child_args) {
748 let path = crate::node_log::path_beside_state(&dir);
749 std::env::set_var(crate::node_log::env_name(), &path);
751 }
752}
753
754fn sidecar_env_path(child_args: &[String]) -> Option<PathBuf> {
755 let name = match extract_enroll_state(child_args) {
756 Some(p) => {
757 let key = p.to_string_lossy();
758 format!("gate4agent-node-{}", short_digest(&key))
759 }
760 None => "gate4agent-node".into(),
761 };
762 #[cfg(windows)]
763 {
764 let base = home_dir().ok()?.join("AppData").join("Local").join("gate4agent-node");
765 return Some(base.join(format!("{name}.env")));
766 }
767 #[cfg(not(windows))]
768 {
769 let base = home_dir().ok()?.join(".local").join("state").join("gate4agent-node");
770 Some(base.join(format!("{name}.env")))
771 }
772}
773
774fn load_sidecar_env(child_args: &[String]) -> Vec<(String, String)> {
775 let Some(path) = sidecar_env_path(child_args) else {
776 return Vec::new();
777 };
778 let Ok(text) = fs::read_to_string(&path) else {
779 return Vec::new();
780 };
781 let mut out = Vec::new();
782 for line in text.lines() {
783 let line = line.trim();
784 if line.is_empty() || line.starts_with('#') {
785 continue;
786 }
787 if let Some((k, v)) = line.split_once('=') {
788 out.push((k.to_owned(), v.to_owned()));
789 }
790 }
791 out
792}
793
794fn apply_sidecar_env(child_args: &[String]) {
795 for (k, v) in load_sidecar_env(child_args) {
796 if std::env::var_os(&k).is_none() {
797 std::env::set_var(&k, &v);
798 }
799 }
800}
801
802fn rotate_log_file(path: &Path, max_bytes: u64) {
803 let Ok(meta) = fs::metadata(path) else {
804 return;
805 };
806 if meta.len() < max_bytes {
807 return;
808 }
809 let bak = path.with_extension("log.1");
810 let _ = fs::remove_file(&bak);
811 let _ = fs::rename(path, bak);
812}
813
814fn read_tail(path: &Path, max_chars: usize) -> String {
815 let Ok(data) = fs::read(path) else {
816 return String::new();
817 };
818 let text = String::from_utf8_lossy(&data);
819 let t = text.trim();
820 if t.len() <= max_chars {
821 return t.to_owned();
822 }
823 t[t.len() - max_chars..].to_owned()
824}
825
826pub fn dispatch(raw_args: &[String]) -> Result<bool, String> {
829 let Some(cmd) = raw_args.first().map(String::as_str) else {
830 return Ok(false);
831 };
832 match cmd {
833 "install" => {
834 let (system, rest) = split_system_and_args(&raw_args[1..]);
835 let node_args = strip_leading_dd(&rest);
836 if node_args.is_empty() {
837 return Err("install requires node arguments after optional --system (and optional --)".into());
838 }
839 let spec = ServiceSpec::from_install_args(system, &node_args)?;
840 let msg = install(&spec)?;
841 println!("{msg}");
842 Ok(true)
843 }
844 "uninstall" => {
845 let (system, _) = split_system_and_args(&raw_args[1..]);
846 println!("{}", uninstall(system)?);
847 Ok(true)
848 }
849 "service-status" => {
850 let (system, _) = split_system_and_args(&raw_args[1..]);
851 let st = status(system)?;
852 println!(
853 "scope={} unit={} installed={} running={} node_health={} detail={}",
854 st.scope, st.unit_name, st.installed, st.running, st.node_health, st.detail
855 );
856 Ok(true)
857 }
858 "service-restart" => {
859 let (system, _) = split_system_and_args(&raw_args[1..]);
860 println!("{}", restart(system)?);
861 Ok(true)
862 }
863 "run-supervised" => {
864 let rest = strip_leading_dd(&raw_args[1..]);
865 if rest.is_empty() {
866 return Err("run-supervised requires `--` and node arguments".into());
867 }
868 run_supervised(&rest);
869 }
870 _ => Ok(false),
871 }
872}
873
874fn split_system_and_args(args: &[String]) -> (bool, Vec<String>) {
875 let mut system = false;
876 let mut rest = Vec::new();
877 for a in args {
878 if a == "--system" {
879 system = true;
880 } else {
881 rest.push(a.clone());
882 }
883 }
884 (system, rest)
885}
886
887fn strip_leading_dd(args: &[String]) -> Vec<String> {
888 match args.first().map(String::as_str) {
889 Some("--") => args[1..].to_vec(),
890 _ => args.to_vec(),
891 }
892}
893
894#[cfg(test)]
895mod spec_tests {
896 use super::*;
897
898 #[test]
899 fn unit_name_stable_for_enroll_path() {
900 let spec = ServiceSpec {
901 system: false,
902 exe: PathBuf::from("/bin/gate4agent-node"),
903 args: vec![
904 "--enrolled".into(),
905 "--enroll-state".into(),
906 "/tmp/enroll-a".into(),
907 ],
908 env: vec![],
909 enroll_state: Some(PathBuf::from("/tmp/enroll-a")),
910 };
911 let n = spec.unit_name();
912 assert!(n.starts_with("gate4agent-node-"));
913 assert_eq!(n, spec.unit_name());
914 assert!(!n.contains('/'));
915 }
916
917 #[test]
918 fn extract_enroll_state_from_args() {
919 let args = vec![
920 "--enrolled".into(),
921 "--enroll-state".into(),
922 "/var/lib/x".into(),
923 ];
924 assert_eq!(
925 extract_enroll_state(&args),
926 Some(PathBuf::from("/var/lib/x"))
927 );
928 }
929}