provider_agent/
service.rs1use std::ffi::OsString;
19use std::path::PathBuf;
20use std::process::Command as ProcCommand;
21
22use anyhow::{Context, Result, bail};
23use service_manager::{
24 RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceLevel, ServiceManager,
25 ServiceStartCtx, ServiceStatus, ServiceStatusCtx, ServiceStopCtx, ServiceUninstallCtx,
26};
27
28pub fn label() -> ServiceLabel {
34 ServiceLabel {
35 qualifier: Some("ai".into()),
36 organization: Some("usepod".into()),
37 application: "agent".into(),
38 }
39}
40
41pub fn working_directory() -> PathBuf {
43 #[cfg(target_os = "linux")]
44 {
45 PathBuf::from("/var/lib/usepod-agent")
46 }
47 #[cfg(target_os = "macos")]
48 {
49 PathBuf::from("/var/lib/usepod-agent")
50 }
51 #[cfg(target_os = "windows")]
52 {
53 let pd = std::env::var("ProgramData").unwrap_or_else(|_| "C:\\ProgramData".into());
56 PathBuf::from(pd).join("usepod-agent")
57 }
58}
59
60pub fn default_log_path() -> PathBuf {
63 #[cfg(target_os = "linux")]
64 {
65 PathBuf::from("/var/log/usepod-agent.log")
66 }
67 #[cfg(target_os = "macos")]
68 {
69 PathBuf::from("/var/log/usepod-agent.log")
70 }
71 #[cfg(target_os = "windows")]
72 {
73 working_directory().join("agent.log")
74 }
75}
76
77#[cfg(target_os = "linux")]
79const SERVICE_USER: &str = "usepod";
80
81#[derive(Debug, Clone)]
82pub struct InstallOptions {
83 pub config: Option<PathBuf>,
85 pub log_level: Option<String>,
87}
88
89pub fn run(action: Action) -> Result<()> {
91 match action {
92 Action::Install(opts) => install(opts),
93 Action::Uninstall => uninstall(),
94 Action::Start => start(),
95 Action::Stop => stop(),
96 Action::Restart => {
97 let _ = stop();
100 start()
101 }
102 Action::Status => status(),
103 Action::Logs { follow } => logs(follow),
104 }
105}
106
107#[derive(Debug, Clone)]
108pub enum Action {
109 Install(InstallOptions),
110 Uninstall,
111 Start,
112 Stop,
113 Restart,
114 Status,
115 Logs { follow: bool },
116}
117
118fn manager() -> Result<Box<dyn ServiceManager>> {
119 let mut m = <dyn ServiceManager>::native()
120 .context("could not detect a supported native service manager (systemd / launchd / SCM)")?;
121 m.set_level(ServiceLevel::System)
125 .context("system-level service install not supported by this platform's service manager")?;
126 Ok(m)
127}
128
129fn build_install_ctx(opts: &InstallOptions) -> Result<ServiceInstallCtx> {
130 let program = std::env::current_exe()
131 .context("could not resolve current executable path for service install")?;
132
133 let mut args: Vec<OsString> = vec!["run".into()];
134 if let Some(p) = &opts.config {
135 args.push("--config".into());
136 args.push(p.as_os_str().to_owned());
137 }
138 if let Some(level) = &opts.log_level {
139 args.push("--log-level".into());
140 args.push(level.into());
141 }
142
143 let working_dir = working_directory();
144
145 let mut environment: Vec<(String, String)> = Vec::new();
148 if cfg!(target_os = "windows") {
149 environment.push((
150 "USEPOD_AGENT_LOG_FILE".into(),
151 default_log_path().to_string_lossy().into_owned(),
152 ));
153 }
154
155 let username = if cfg!(target_os = "linux") {
159 Some(SERVICE_USER.to_string())
160 } else {
161 None
162 };
163
164 Ok(ServiceInstallCtx {
165 label: label(),
166 program,
167 args,
168 contents: platform_contents(&working_dir),
169 username,
170 working_directory: Some(working_dir),
171 environment: if environment.is_empty() {
172 None
173 } else {
174 Some(environment)
175 },
176 autostart: true,
177 restart_policy: RestartPolicy::Always {
183 delay_secs: Some(5),
184 },
185 })
186}
187
188fn platform_contents(_working_dir: &std::path::Path) -> Option<String> {
192 #[cfg(target_os = "linux")]
195 {
196 let exe = std::env::current_exe()
197 .ok()
198 .map(|p| p.to_string_lossy().into_owned())
199 .unwrap_or_else(|| "/usr/local/bin/usepod-agent".into());
200 return Some(format!(
201 r#"[Unit]
202Description=Use Pod Provider Agent
203Documentation=https://usepod.ai/docs/agent
204After=network-online.target
205Wants=network-online.target
206
207[Service]
208Type=simple
209User={user}
210Group={user}
211WorkingDirectory={wd}
212ExecStart={exe} run
213Restart=always
214RestartSec=5
215
216# --- Hardening (kept in sync with install/usepod-agent.service) -------------
217NoNewPrivileges=true
218ProtectSystem=strict
219ProtectHome=true
220PrivateTmp=true
221ReadWritePaths={wd}
222ProtectKernelTunables=true
223ProtectKernelModules=true
224ProtectControlGroups=true
225RestrictSUIDSGID=true
226LockPersonality=true
227
228[Install]
229WantedBy=multi-user.target
230"#,
231 user = SERVICE_USER,
232 wd = _working_dir.display(),
233 exe = exe,
234 ));
235 }
236
237 #[cfg(target_os = "macos")]
241 {
242 let exe = std::env::current_exe()
243 .ok()
244 .map(|p| p.to_string_lossy().into_owned())
245 .unwrap_or_else(|| "/usr/local/bin/usepod-agent".into());
246 let log = default_log_path().to_string_lossy().into_owned();
247 return Some(format!(
248 r#"<?xml version="1.0" encoding="UTF-8"?>
249<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
250<plist version="1.0">
251<dict>
252 <key>Label</key><string>ai.usepod.agent</string>
253 <key>ProgramArguments</key>
254 <array>
255 <string>{exe}</string>
256 <string>run</string>
257 </array>
258 <key>WorkingDirectory</key><string>{wd}</string>
259 <key>RunAtLoad</key><true/>
260 <key>KeepAlive</key>
261 <dict>
262 <key>SuccessfulExit</key><false/>
263 </dict>
264 <key>StandardOutPath</key><string>{log}</string>
265 <key>StandardErrorPath</key><string>{log}</string>
266</dict>
267</plist>
268"#,
269 exe = exe,
270 wd = _working_dir.display(),
271 log = log,
272 ));
273 }
274
275 #[cfg(target_os = "windows")]
279 {
280 return None;
281 }
282
283 #[allow(unreachable_code)]
284 None
285}
286
287fn install(opts: InstallOptions) -> Result<()> {
288 require_elevated("install")?;
289 ensure_working_directory()?;
290
291 #[cfg(target_os = "linux")]
292 {
293 ensure_linux_user(SERVICE_USER)?;
294 chown_working_directory(SERVICE_USER, &working_directory())?;
295 }
296
297 let m = manager()?;
298 let ctx = build_install_ctx(&opts)?;
299 m.install(ctx).context("service install failed")?;
300
301 let status_hint = match m.status(ServiceStatusCtx { label: label() }) {
302 Ok(ServiceStatus::Running) => "running",
303 Ok(ServiceStatus::Stopped(_)) => "installed (stopped)",
304 Ok(ServiceStatus::NotInstalled) => "installed (not yet started)",
305 Err(_) => "installed",
306 };
307 println!("✓ usepod-agent service: {status_hint}");
308 println!(" start: usepod-agent service start");
309 println!(" status: usepod-agent service status");
310 println!(" logs: usepod-agent service logs -f");
311 Ok(())
312}
313
314fn uninstall() -> Result<()> {
315 require_elevated("uninstall")?;
316 let m = manager()?;
317 let _ = m.stop(ServiceStopCtx { label: label() });
319 m.uninstall(ServiceUninstallCtx { label: label() })
320 .context("service uninstall failed")?;
321 println!("✓ usepod-agent service uninstalled");
322 Ok(())
323}
324
325fn start() -> Result<()> {
326 let m = manager()?;
327 m.start(ServiceStartCtx { label: label() })
328 .context("service start failed (try with sudo / as Administrator)")?;
329 println!("✓ usepod-agent service started");
330 Ok(())
331}
332
333fn stop() -> Result<()> {
334 let m = manager()?;
335 m.stop(ServiceStopCtx { label: label() })
336 .context("service stop failed (try with sudo / as Administrator)")?;
337 println!("✓ usepod-agent service stopped");
338 Ok(())
339}
340
341fn status() -> Result<()> {
342 let m = manager()?;
343 match m.status(ServiceStatusCtx { label: label() }) {
344 Ok(ServiceStatus::Running) => {
345 println!("running");
346 Ok(())
347 }
348 Ok(ServiceStatus::Stopped(reason)) => {
349 match reason {
350 Some(r) => println!("stopped: {r}"),
351 None => println!("stopped"),
352 }
353 Ok(())
354 }
355 Ok(ServiceStatus::NotInstalled) => {
356 println!("not installed");
357 std::process::exit(3);
358 }
359 Err(e) => Err(anyhow::Error::from(e).context("service status query failed")),
360 }
361}
362
363fn logs(follow: bool) -> Result<()> {
364 #[cfg(target_os = "linux")]
365 {
366 let mut cmd = ProcCommand::new("journalctl");
367 cmd.arg("-u").arg("usepod-agent");
368 if follow {
369 cmd.arg("-f");
370 } else {
371 cmd.arg("-n").arg("200").arg("--no-pager");
372 }
373 let status = cmd.status().context("failed to invoke journalctl")?;
374 if !status.success() {
375 bail!("journalctl exited with {status}");
376 }
377 return Ok(());
378 }
379
380 #[cfg(any(target_os = "macos", target_os = "windows"))]
381 {
382 let path = default_log_path();
383 if !path.exists() {
384 bail!(
385 "log file does not yet exist at {} (service may not have run yet)",
386 path.display()
387 );
388 }
389 let mut cmd = if cfg!(target_os = "windows") {
390 let mut c = ProcCommand::new("powershell");
392 c.arg("-NoProfile").arg("-Command");
393 if follow {
394 c.arg(format!("Get-Content -Path '{}' -Wait -Tail 200", path.display()));
395 } else {
396 c.arg(format!("Get-Content -Path '{}' -Tail 200", path.display()));
397 }
398 c
399 } else {
400 let mut c = ProcCommand::new("tail");
401 c.arg("-n").arg("200");
402 if follow {
403 c.arg("-f");
404 }
405 c.arg(&path);
406 c
407 };
408 let status = cmd.status().context("failed to invoke log tailer")?;
409 if !status.success() {
410 bail!("log tailer exited with {status}");
411 }
412 return Ok(());
413 }
414
415 #[allow(unreachable_code)]
416 {
417 bail!("`service logs` not implemented for this platform");
418 }
419}
420
421fn require_elevated(action: &str) -> Result<()> {
424 if is_elevated() {
425 return Ok(());
426 }
427 let hint = if cfg!(target_os = "windows") {
428 "re-run from an elevated PowerShell (Run as Administrator)"
429 } else {
430 "re-run with sudo"
431 };
432 bail!("`service {action}` needs root/Administrator privileges; {hint}");
433}
434
435#[cfg(unix)]
436fn is_elevated() -> bool {
437 unsafe { libc_getuid() == 0 }
439}
440
441#[cfg(unix)]
442unsafe extern "C" {
443 #[link_name = "getuid"]
444 fn libc_getuid() -> u32;
445}
446
447#[cfg(target_os = "windows")]
448fn is_elevated() -> bool {
449 if std::env::var("USEPOD_AGENT_FORCE_ELEVATED").is_ok() {
455 return true;
456 }
457 ProcCommand::new("net")
461 .arg("session")
462 .stdout(std::process::Stdio::null())
463 .stderr(std::process::Stdio::null())
464 .status()
465 .map(|s| s.success())
466 .unwrap_or(false)
467}
468
469fn ensure_working_directory() -> Result<()> {
470 let wd = working_directory();
471 if !wd.exists() {
472 std::fs::create_dir_all(&wd)
473 .with_context(|| format!("could not create {}", wd.display()))?;
474 }
475 Ok(())
476}
477
478#[cfg(target_os = "linux")]
479fn ensure_linux_user(user: &str) -> Result<()> {
480 let exists = ProcCommand::new("id")
482 .arg(user)
483 .stdout(std::process::Stdio::null())
484 .stderr(std::process::Stdio::null())
485 .status()
486 .map(|s| s.success())
487 .unwrap_or(false);
488 if exists {
489 return Ok(());
490 }
491 let status = ProcCommand::new("useradd")
492 .arg("--system")
493 .arg("--no-create-home")
494 .arg("--shell")
495 .arg("/usr/sbin/nologin")
496 .arg(user)
497 .status()
498 .context("failed to invoke useradd; install shadow-utils or create the user manually")?;
499 if !status.success() {
500 bail!(
501 "useradd exited with {status}; create the `{user}` system user manually then retry"
502 );
503 }
504 Ok(())
505}
506
507#[cfg(target_os = "linux")]
508fn chown_working_directory(user: &str, wd: &std::path::Path) -> Result<()> {
509 let status = ProcCommand::new("chown")
510 .arg("-R")
511 .arg(format!("{user}:{user}"))
512 .arg(wd)
513 .status()
514 .context("failed to invoke chown")?;
515 if !status.success() {
516 bail!("chown exited with {status}");
517 }
518 Ok(())
519}
520
521#[cfg(not(target_os = "linux"))]
523#[allow(dead_code)]
524fn ensure_linux_user(_user: &str) -> Result<()> {
525 Ok(())
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn label_renders_per_platform_correctly() {
534 let l = label();
535 assert_eq!(l.to_script_name(), "usepod-agent");
537 assert_eq!(l.to_qualified_name(), "ai.usepod.agent");
539 }
540
541 #[test]
542 fn install_ctx_carries_run_subcommand() {
543 let opts = InstallOptions {
544 config: None,
545 log_level: None,
546 };
547 let ctx = build_install_ctx(&opts).expect("ctx builds");
548 assert_eq!(ctx.args.first().map(|s| s.as_os_str()), Some(std::ffi::OsStr::new("run")));
549 assert!(ctx.autostart);
550 }
551
552 #[test]
553 fn install_ctx_propagates_config_and_log_level() {
554 let opts = InstallOptions {
555 config: Some(PathBuf::from("/etc/usepod/agent.toml")),
556 log_level: Some("debug".into()),
557 };
558 let ctx = build_install_ctx(&opts).expect("ctx builds");
559 let args: Vec<String> = ctx
560 .args
561 .iter()
562 .map(|s| s.to_string_lossy().into_owned())
563 .collect();
564 assert_eq!(args, vec!["run", "--config", "/etc/usepod/agent.toml", "--log-level", "debug"]);
565 }
566
567 #[test]
568 fn restart_policy_is_always() {
569 let ctx = build_install_ctx(&InstallOptions {
570 config: None,
571 log_level: None,
572 })
573 .unwrap();
574 assert!(matches!(ctx.restart_policy, RestartPolicy::Always { .. }));
575 }
576}