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