Skip to main content

orch/cli/
service.rs

1use crate::cmd::SyncCommandErrorContext;
2#[cfg(any(test, not(target_os = "macos")))]
3use anyhow::Context;
4#[cfg(any(test, not(target_os = "macos")))]
5use std::path::{Path, PathBuf};
6
7/// Run a service subcommand for orch: `brew services` on macOS, the `orch` systemd user unit elsewhere.
8fn service(action: &str) -> anyhow::Result<()> {
9    let (program, args) = if cfg!(target_os = "macos") {
10        ("brew", vec!["services", action, "orch"])
11    } else {
12        // brew's `info` is systemd's `status`
13        let action = if action == "info" { "status" } else { action };
14        ("systemctl", vec!["--user", action, "orch"])
15    };
16
17    let status = std::process::Command::new(program)
18        .args(&args)
19        .status_with_context()?;
20
21    if !status.success() {
22        anyhow::bail!("{program} {} failed", args.join(" "));
23    }
24
25    Ok(())
26}
27
28/// Start the orch service.
29pub fn start() -> anyhow::Result<()> {
30    service("start")
31}
32
33/// Stop the orch service.
34pub fn stop() -> anyhow::Result<()> {
35    service("stop")
36}
37
38/// Restart the orch service.
39pub fn restart() -> anyhow::Result<()> {
40    service("restart")
41}
42
43/// Show service status.
44pub fn status() -> anyhow::Result<()> {
45    service("info")
46}
47
48/// Write the systemd user unit and enable it
49pub fn install(force: bool) -> anyhow::Result<()> {
50    #[cfg(target_os = "macos")]
51    {
52        let _ = force;
53        macos_service_owned_by_homebrew();
54        Ok(())
55    }
56    #[cfg(not(target_os = "macos"))]
57    {
58        linux_install(
59            &systemd_user_dir()?,
60            &current_binary()?,
61            Path::new("systemctl"),
62            force,
63        )
64    }
65}
66
67/// Disable the systemd user unit and remove the file
68pub fn uninstall() -> anyhow::Result<()> {
69    #[cfg(target_os = "macos")]
70    {
71        macos_service_owned_by_homebrew();
72        Ok(())
73    }
74    #[cfg(not(target_os = "macos"))]
75    {
76        linux_uninstall(&systemd_user_dir()?, Path::new("systemctl"))
77    }
78}
79
80#[cfg(any(test, target_os = "macos"))]
81fn macos_service_owned_by_homebrew() {
82    println!("The Homebrew formula already installs the orch service.");
83}
84
85#[cfg(test)]
86fn install_os(
87    macos: bool,
88    unit_dir: &Path,
89    binary: &Path,
90    systemctl: &Path,
91    force: bool,
92) -> anyhow::Result<()> {
93    if macos {
94        macos_service_owned_by_homebrew();
95        return Ok(());
96    }
97    linux_install(unit_dir, binary, systemctl, force)
98}
99
100#[cfg(test)]
101fn uninstall_os(macos: bool, unit_dir: &Path, systemctl: &Path) -> anyhow::Result<()> {
102    if macos {
103        macos_service_owned_by_homebrew();
104        return Ok(());
105    }
106    linux_uninstall(unit_dir, systemctl)
107}
108
109#[cfg(any(test, not(target_os = "macos")))]
110fn linux_install(
111    unit_dir: &Path,
112    binary: &Path,
113    systemctl: &Path,
114    force: bool,
115) -> anyhow::Result<()> {
116    let unit_path = unit_dir.join("orch.service");
117    let unit = unit_contents(binary);
118    if unit_path.exists() && !force {
119        let existing = std::fs::read_to_string(&unit_path)
120            .with_context(|| format!("reading {}", unit_path.display()))?;
121        if existing != unit {
122            print_unit_diff(&existing, &unit);
123        }
124        anyhow::bail!(
125            "unit already exists at {}; pass --force to overwrite",
126            unit_path.display()
127        );
128    }
129    std::fs::create_dir_all(unit_dir)
130        .with_context(|| format!("creating {}", unit_dir.display()))?;
131    std::fs::write(&unit_path, &unit)
132        .with_context(|| format!("writing {}", unit_path.display()))?;
133    println!("Wrote {}", unit_path.display());
134    run_systemctl(systemctl, &["daemon-reload"])?;
135    run_systemctl(systemctl, &["enable", "--now", "orch"])?;
136    println!("Enabled and started the orch user service.");
137    println!("A user service stops at logout unless lingering is on.");
138    println!("  loginctl enable-linger $USER");
139    Ok(())
140}
141
142#[cfg(any(test, not(target_os = "macos")))]
143fn linux_uninstall(unit_dir: &Path, systemctl: &Path) -> anyhow::Result<()> {
144    let unit_path = unit_dir.join("orch.service");
145    if !unit_path.exists() {
146        let _ = run_systemctl(systemctl, &["disable", "--now", "orch"]);
147        println!("No orch user unit at {}", unit_path.display());
148        return Ok(());
149    }
150    run_systemctl(systemctl, &["disable", "--now", "orch"])?;
151    std::fs::remove_file(&unit_path)
152        .with_context(|| format!("removing {}", unit_path.display()))?;
153    println!("Removed {}", unit_path.display());
154    run_systemctl(systemctl, &["daemon-reload"])?;
155    Ok(())
156}
157
158#[cfg(any(test, not(target_os = "macos")))]
159fn unit_contents(binary: &Path) -> String {
160    format!(
161        "[Unit]\n\
162         Description=orch agent orchestrator\n\
163         After=network-online.target\n\
164         \n\
165         [Service]\n\
166         ExecStart={} serve\n\
167         Restart=always\n\
168         RestartSec=5\n\
169         \n\
170         [Install]\n\
171         WantedBy=default.target\n",
172        exec_start_bin(binary)
173    )
174}
175
176#[cfg(any(test, not(target_os = "macos")))]
177fn exec_start_bin(binary: &Path) -> String {
178    let path = binary.display().to_string();
179    if path.chars().any(char::is_whitespace) {
180        format!("\"{path}\"")
181    } else {
182        path
183    }
184}
185
186#[cfg(any(test, not(target_os = "macos")))]
187fn print_unit_diff(existing: &str, proposed: &str) {
188    println!("--- existing");
189    println!("+++ proposed");
190    let old: Vec<&str> = existing.lines().collect();
191    let new: Vec<&str> = proposed.lines().collect();
192    let len = old.len().max(new.len());
193    for i in 0..len {
194        match (old.get(i), new.get(i)) {
195            (Some(a), Some(b)) if a == b => println!(" {a}"),
196            (Some(a), Some(b)) => {
197                println!("-{a}");
198                println!("+{b}");
199            }
200            (Some(a), None) => println!("-{a}"),
201            (None, Some(b)) => println!("+{b}"),
202            (None, None) => {}
203        }
204    }
205}
206
207#[cfg(any(test, not(target_os = "macos")))]
208fn run_systemctl(systemctl: &Path, args: &[&str]) -> anyhow::Result<()> {
209    let status = std::process::Command::new(systemctl)
210        .arg("--user")
211        .args(args)
212        .status_with_context()?;
213    if !status.success() {
214        anyhow::bail!("{} --user {} failed", systemctl.display(), args.join(" "));
215    }
216    Ok(())
217}
218
219#[cfg(not(target_os = "macos"))]
220fn systemd_user_dir() -> anyhow::Result<PathBuf> {
221    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
222        if !xdg.is_empty() {
223            return Ok(PathBuf::from(xdg).join("systemd").join("user"));
224        }
225    }
226    let home = dirs::home_dir().context("cannot determine home directory")?;
227    Ok(home.join(".config").join("systemd").join("user"))
228}
229
230#[cfg(not(target_os = "macos"))]
231fn current_binary() -> anyhow::Result<PathBuf> {
232    let exe = std::env::current_exe().context("cannot resolve the orch binary path")?;
233    Ok(exe.canonicalize().unwrap_or(exe))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::fs;
240    use std::os::unix::fs::PermissionsExt;
241
242    fn stub_systemctl(dir: &Path) -> PathBuf {
243        let stub = dir.join("systemctl");
244        let log = dir.join("systemctl.log");
245        fs::write(
246            &stub,
247            format!(
248                "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\nexit 0\n",
249                log.display()
250            ),
251        )
252        .unwrap();
253        fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap();
254        stub
255    }
256
257    fn setup() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
258        let root = tempfile::tempdir().unwrap();
259        let unit_dir = root.path().join("user");
260        fs::create_dir_all(&unit_dir).unwrap();
261        let stub = stub_systemctl(root.path());
262        let binary = PathBuf::from("/opt/orch/bin/orch");
263        (root, unit_dir, binary, stub)
264    }
265
266    fn unit_path(unit_dir: &Path) -> PathBuf {
267        unit_dir.join("orch.service")
268    }
269
270    #[test]
271    fn install_writes_unit_with_binary_path_and_serve() {
272        let (root, unit_dir, binary, stub) = setup();
273        install_os(false, &unit_dir, &binary, &stub, false).unwrap();
274        let unit = fs::read_to_string(unit_path(&unit_dir)).unwrap();
275        assert!(
276            unit.contains("ExecStart=/opt/orch/bin/orch serve"),
277            "{unit}"
278        );
279        assert!(unit.contains("Restart=always"), "{unit}");
280        assert!(unit.contains("WantedBy=default.target"), "{unit}");
281        assert!(!unit.contains("Environment"), "{unit}");
282        let log = fs::read_to_string(root.path().join("systemctl.log")).unwrap();
283        assert!(log.contains("daemon-reload"), "{log}");
284        assert!(log.contains("enable --now orch"), "{log}");
285    }
286
287    #[test]
288    fn second_install_without_force_refuses_and_leaves_file() {
289        let (_root, unit_dir, binary, stub) = setup();
290        install_os(false, &unit_dir, &binary, &stub, false).unwrap();
291        let path = unit_path(&unit_dir);
292        let original = fs::read_to_string(&path).unwrap();
293        fs::write(&path, original.replace("RestartSec=5", "RestartSec=99")).unwrap();
294        let changed = fs::read_to_string(&path).unwrap();
295        let err = install_os(false, &unit_dir, &binary, &stub, false).unwrap_err();
296        assert!(
297            err.to_string().contains("--force"),
298            "error should mention --force: {err}"
299        );
300        assert_eq!(fs::read_to_string(&path).unwrap(), changed);
301    }
302
303    #[test]
304    fn uninstall_removes_the_unit() {
305        let (_root, unit_dir, binary, stub) = setup();
306        install_os(false, &unit_dir, &binary, &stub, false).unwrap();
307        assert!(unit_path(&unit_dir).exists());
308        uninstall_os(false, &unit_dir, &stub).unwrap();
309        assert!(!unit_path(&unit_dir).exists());
310    }
311
312    #[test]
313    fn macos_install_changes_nothing() {
314        let (_root, unit_dir, binary, stub) = setup();
315        #[cfg(target_os = "macos")]
316        {
317            install(false).unwrap();
318            uninstall().unwrap();
319        }
320        install_os(true, &unit_dir, &binary, &stub, false).unwrap();
321        install_os(true, &unit_dir, &binary, &stub, true).unwrap();
322        assert!(!unit_path(&unit_dir).exists());
323        assert!(unit_dir.read_dir().unwrap().next().is_none());
324    }
325}