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 #[cfg(target_os = "linux")]
161 let username = Some(SERVICE_USER.to_string());
162 #[cfg(not(target_os = "linux"))]
163 let username: Option<String> = None;
164
165 Ok(ServiceInstallCtx {
166 label: label(),
167 program,
168 args,
169 contents: platform_contents(&working_dir),
170 username,
171 working_directory: Some(working_dir),
172 environment: if environment.is_empty() {
173 None
174 } else {
175 Some(environment)
176 },
177 autostart: true,
178 restart_policy: RestartPolicy::Always {
184 delay_secs: Some(5),
185 },
186 })
187}
188
189fn platform_contents(_working_dir: &std::path::Path) -> Option<String> {
193 #[cfg(target_os = "linux")]
196 {
197 let exe = std::env::current_exe()
198 .ok()
199 .map(|p| p.to_string_lossy().into_owned())
200 .unwrap_or_else(|| "/usr/local/bin/usepod-agent".into());
201 return Some(format!(
202 r#"[Unit]
203Description=Use Pod Provider Agent
204Documentation=https://usepod.ai/docs/agent
205After=network-online.target
206Wants=network-online.target
207
208[Service]
209Type=simple
210User={user}
211Group={user}
212WorkingDirectory={wd}
213ExecStart={exe} run
214Restart=always
215RestartSec=5
216
217# --- Hardening (kept in sync with install/usepod-agent.service) -------------
218NoNewPrivileges=true
219ProtectSystem=strict
220ProtectHome=true
221PrivateTmp=true
222ReadWritePaths={wd}
223ProtectKernelTunables=true
224ProtectKernelModules=true
225ProtectControlGroups=true
226RestrictSUIDSGID=true
227LockPersonality=true
228
229[Install]
230WantedBy=multi-user.target
231"#,
232 user = SERVICE_USER,
233 wd = _working_dir.display(),
234 exe = exe,
235 ));
236 }
237
238 #[cfg(target_os = "macos")]
242 {
243 let exe = std::env::current_exe()
244 .ok()
245 .map(|p| p.to_string_lossy().into_owned())
246 .unwrap_or_else(|| "/usr/local/bin/usepod-agent".into());
247 let log = default_log_path().to_string_lossy().into_owned();
248 return Some(format!(
249 r#"<?xml version="1.0" encoding="UTF-8"?>
250<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
251<plist version="1.0">
252<dict>
253 <key>Label</key><string>ai.usepod.agent</string>
254 <key>ProgramArguments</key>
255 <array>
256 <string>{exe}</string>
257 <string>run</string>
258 </array>
259 <key>WorkingDirectory</key><string>{wd}</string>
260 <key>RunAtLoad</key><true/>
261 <key>KeepAlive</key>
262 <dict>
263 <key>SuccessfulExit</key><false/>
264 </dict>
265 <key>StandardOutPath</key><string>{log}</string>
266 <key>StandardErrorPath</key><string>{log}</string>
267</dict>
268</plist>
269"#,
270 exe = exe,
271 wd = _working_dir.display(),
272 log = log,
273 ));
274 }
275
276 #[cfg(target_os = "windows")]
280 {
281 return None;
282 }
283
284 #[allow(unreachable_code)]
285 None
286}
287
288fn install(opts: InstallOptions) -> Result<()> {
289 require_elevated("install")?;
290 ensure_working_directory()?;
291
292 #[cfg(target_os = "linux")]
293 {
294 ensure_linux_user(SERVICE_USER)?;
295 chown_working_directory(SERVICE_USER, &working_directory())?;
296 }
297
298 let m = manager()?;
299 let ctx = build_install_ctx(&opts)?;
300 m.install(ctx).context("service install failed")?;
301
302 let status_hint = match m.status(ServiceStatusCtx { label: label() }) {
303 Ok(ServiceStatus::Running) => "running",
304 Ok(ServiceStatus::Stopped(_)) => "installed (stopped)",
305 Ok(ServiceStatus::NotInstalled) => "installed (not yet started)",
306 Err(_) => "installed",
307 };
308 println!("✓ usepod-agent service: {status_hint}");
309 println!(" start: usepod-agent service start");
310 println!(" status: usepod-agent service status");
311 println!(" logs: usepod-agent service logs -f");
312 Ok(())
313}
314
315fn uninstall() -> Result<()> {
316 require_elevated("uninstall")?;
317 let m = manager()?;
318 let _ = m.stop(ServiceStopCtx { label: label() });
320 m.uninstall(ServiceUninstallCtx { label: label() })
321 .context("service uninstall failed")?;
322 println!("✓ usepod-agent service uninstalled");
323 Ok(())
324}
325
326fn start() -> Result<()> {
327 let m = manager()?;
328 m.start(ServiceStartCtx { label: label() })
329 .context("service start failed (try with sudo / as Administrator)")?;
330 println!("✓ usepod-agent service started");
331 Ok(())
332}
333
334fn stop() -> Result<()> {
335 let m = manager()?;
336 m.stop(ServiceStopCtx { label: label() })
337 .context("service stop failed (try with sudo / as Administrator)")?;
338 println!("✓ usepod-agent service stopped");
339 Ok(())
340}
341
342fn status() -> Result<()> {
343 let m = manager()?;
344 match m.status(ServiceStatusCtx { label: label() }) {
345 Ok(ServiceStatus::Running) => {
346 println!("running");
347 Ok(())
348 }
349 Ok(ServiceStatus::Stopped(reason)) => {
350 match reason {
351 Some(r) => println!("stopped: {r}"),
352 None => println!("stopped"),
353 }
354 Ok(())
355 }
356 Ok(ServiceStatus::NotInstalled) => {
357 println!("not installed");
358 std::process::exit(3);
359 }
360 Err(e) => Err(anyhow::Error::from(e).context("service status query failed")),
361 }
362}
363
364fn logs(follow: bool) -> Result<()> {
365 #[cfg(target_os = "linux")]
366 {
367 let mut cmd = ProcCommand::new("journalctl");
368 cmd.arg("-u").arg("usepod-agent");
369 if follow {
370 cmd.arg("-f");
371 } else {
372 cmd.arg("-n").arg("200").arg("--no-pager");
373 }
374 let status = cmd.status().context("failed to invoke journalctl")?;
375 if !status.success() {
376 bail!("journalctl exited with {status}");
377 }
378 return Ok(());
379 }
380
381 #[cfg(any(target_os = "macos", target_os = "windows"))]
382 {
383 let path = default_log_path();
384 if !path.exists() {
385 bail!(
386 "log file does not yet exist at {} (service may not have run yet)",
387 path.display()
388 );
389 }
390 let mut cmd = if cfg!(target_os = "windows") {
391 let mut c = ProcCommand::new("powershell");
393 c.arg("-NoProfile").arg("-Command");
394 if follow {
395 c.arg(format!("Get-Content -Path '{}' -Wait -Tail 200", path.display()));
396 } else {
397 c.arg(format!("Get-Content -Path '{}' -Tail 200", path.display()));
398 }
399 c
400 } else {
401 let mut c = ProcCommand::new("tail");
402 c.arg("-n").arg("200");
403 if follow {
404 c.arg("-f");
405 }
406 c.arg(&path);
407 c
408 };
409 let status = cmd.status().context("failed to invoke log tailer")?;
410 if !status.success() {
411 bail!("log tailer exited with {status}");
412 }
413 return Ok(());
414 }
415
416 #[allow(unreachable_code)]
417 {
418 bail!("`service logs` not implemented for this platform");
419 }
420}
421
422fn require_elevated(action: &str) -> Result<()> {
425 if is_elevated() {
426 return Ok(());
427 }
428 let hint = if cfg!(target_os = "windows") {
429 "re-run from an elevated PowerShell (Run as Administrator)"
430 } else {
431 "re-run with sudo"
432 };
433 bail!("`service {action}` needs root/Administrator privileges; {hint}");
434}
435
436#[cfg(unix)]
437fn is_elevated() -> bool {
438 unsafe { libc_getuid() == 0 }
440}
441
442#[cfg(unix)]
443unsafe extern "C" {
444 #[link_name = "getuid"]
445 fn libc_getuid() -> u32;
446}
447
448#[cfg(target_os = "windows")]
449fn is_elevated() -> bool {
450 if std::env::var("USEPOD_AGENT_FORCE_ELEVATED").is_ok() {
456 return true;
457 }
458 ProcCommand::new("net")
462 .arg("session")
463 .stdout(std::process::Stdio::null())
464 .stderr(std::process::Stdio::null())
465 .status()
466 .map(|s| s.success())
467 .unwrap_or(false)
468}
469
470fn ensure_working_directory() -> Result<()> {
471 let wd = working_directory();
472 if !wd.exists() {
473 std::fs::create_dir_all(&wd)
474 .with_context(|| format!("could not create {}", wd.display()))?;
475 }
476 Ok(())
477}
478
479#[cfg(target_os = "linux")]
480fn ensure_linux_user(user: &str) -> Result<()> {
481 let exists = ProcCommand::new("id")
483 .arg(user)
484 .stdout(std::process::Stdio::null())
485 .stderr(std::process::Stdio::null())
486 .status()
487 .map(|s| s.success())
488 .unwrap_or(false);
489 if exists {
490 return Ok(());
491 }
492 let status = ProcCommand::new("useradd")
493 .arg("--system")
494 .arg("--no-create-home")
495 .arg("--shell")
496 .arg("/usr/sbin/nologin")
497 .arg(user)
498 .status()
499 .context("failed to invoke useradd; install shadow-utils or create the user manually")?;
500 if !status.success() {
501 bail!(
502 "useradd exited with {status}; create the `{user}` system user manually then retry"
503 );
504 }
505 Ok(())
506}
507
508#[cfg(target_os = "linux")]
509fn chown_working_directory(user: &str, wd: &std::path::Path) -> Result<()> {
510 let status = ProcCommand::new("chown")
511 .arg("-R")
512 .arg(format!("{user}:{user}"))
513 .arg(wd)
514 .status()
515 .context("failed to invoke chown")?;
516 if !status.success() {
517 bail!("chown exited with {status}");
518 }
519 Ok(())
520}
521
522#[cfg(not(target_os = "linux"))]
524#[allow(dead_code)]
525fn ensure_linux_user(_user: &str) -> Result<()> {
526 Ok(())
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn label_renders_per_platform_correctly() {
535 let l = label();
536 assert_eq!(l.to_script_name(), "usepod-agent");
538 assert_eq!(l.to_qualified_name(), "ai.usepod.agent");
540 }
541
542 #[test]
543 fn install_ctx_carries_run_subcommand() {
544 let opts = InstallOptions {
545 config: None,
546 log_level: None,
547 };
548 let ctx = build_install_ctx(&opts).expect("ctx builds");
549 assert_eq!(ctx.args.first().map(|s| s.as_os_str()), Some(std::ffi::OsStr::new("run")));
550 assert!(ctx.autostart);
551 }
552
553 #[test]
554 fn install_ctx_propagates_config_and_log_level() {
555 let opts = InstallOptions {
556 config: Some(PathBuf::from("/etc/usepod/agent.toml")),
557 log_level: Some("debug".into()),
558 };
559 let ctx = build_install_ctx(&opts).expect("ctx builds");
560 let args: Vec<String> = ctx
561 .args
562 .iter()
563 .map(|s| s.to_string_lossy().into_owned())
564 .collect();
565 assert_eq!(args, vec!["run", "--config", "/etc/usepod/agent.toml", "--log-level", "debug"]);
566 }
567
568 #[test]
569 fn restart_policy_is_always() {
570 let ctx = build_install_ctx(&InstallOptions {
571 config: None,
572 log_level: None,
573 })
574 .unwrap();
575 assert!(matches!(ctx.restart_policy, RestartPolicy::Always { .. }));
576 }
577}