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(not(target_os = "macos"))]
5use std::env;
6#[cfg(any(test, not(target_os = "macos")))]
7use std::fs;
8#[cfg(any(test, not(target_os = "macos")))]
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12/// Run a service subcommand for orch: `brew services` on macOS, the `orch` systemd user unit elsewhere.
13fn service(action: &str) -> anyhow::Result<()> {
14    let (program, args) = if cfg!(target_os = "macos") {
15        ("brew", vec!["services", action, "orch"])
16    } else {
17        // brew's `info` is systemd's `status`
18        let action = if action == "info" { "status" } else { action };
19        ("systemctl", vec!["--user", action, "orch"])
20    };
21
22    let status = Command::new(program).args(&args).status_with_context()?;
23
24    if !status.success() {
25        anyhow::bail!("{program} {} failed", args.join(" "));
26    }
27
28    Ok(())
29}
30
31/// Start the orch service.
32pub fn start() -> anyhow::Result<()> {
33    service("start")
34}
35
36/// Stop the orch service.
37pub fn stop() -> anyhow::Result<()> {
38    service("stop")
39}
40
41/// Restart the orch service.
42pub fn restart() -> anyhow::Result<()> {
43    service("restart")
44}
45
46/// Show service status.
47pub fn status() -> anyhow::Result<()> {
48    service("info")
49}
50
51/// Write the systemd user unit and enable it
52pub fn install(force: bool) -> anyhow::Result<()> {
53    #[cfg(target_os = "macos")]
54    {
55        let _ = force;
56        macos_service_owned_by_homebrew();
57        Ok(())
58    }
59    #[cfg(not(target_os = "macos"))]
60    {
61        linux_install(
62            &systemd_user_dir()?,
63            &current_binary()?,
64            Path::new("systemctl"),
65            force,
66        )
67    }
68}
69
70/// Disable the systemd user unit and remove the file
71pub fn uninstall() -> anyhow::Result<()> {
72    #[cfg(target_os = "macos")]
73    {
74        macos_service_owned_by_homebrew();
75        Ok(())
76    }
77    #[cfg(not(target_os = "macos"))]
78    {
79        linux_uninstall(&systemd_user_dir()?, Path::new("systemctl"))
80    }
81}
82
83#[cfg(any(test, target_os = "macos"))]
84fn macos_service_owned_by_homebrew() {
85    println!("The Homebrew formula already installs the orch service.");
86}
87
88#[cfg(test)]
89fn install_os(
90    macos: bool,
91    unit_dir: &Path,
92    binary: &Path,
93    systemctl: &Path,
94    force: bool,
95) -> anyhow::Result<()> {
96    if macos {
97        macos_service_owned_by_homebrew();
98        return Ok(());
99    }
100    linux_install(unit_dir, binary, systemctl, force)
101}
102
103#[cfg(test)]
104fn uninstall_os(macos: bool, unit_dir: &Path, systemctl: &Path) -> anyhow::Result<()> {
105    if macos {
106        macos_service_owned_by_homebrew();
107        return Ok(());
108    }
109    linux_uninstall(unit_dir, systemctl)
110}
111
112#[cfg(any(test, not(target_os = "macos")))]
113fn linux_install(
114    unit_dir: &Path,
115    binary: &Path,
116    systemctl: &Path,
117    force: bool,
118) -> anyhow::Result<()> {
119    let unit_path = unit_dir.join("orch.service");
120    let unit = unit_contents(binary);
121    if unit_path.exists() && !force {
122        let existing = fs::read_to_string(&unit_path)
123            .with_context(|| format!("reading {}", unit_path.display()))?;
124        if existing != unit {
125            print_unit_diff(&existing, &unit);
126        }
127        anyhow::bail!(
128            "unit already exists at {}; pass --force to overwrite",
129            unit_path.display()
130        );
131    }
132    fs::create_dir_all(unit_dir).with_context(|| format!("creating {}", unit_dir.display()))?;
133    fs::write(&unit_path, &unit).with_context(|| format!("writing {}", unit_path.display()))?;
134    println!("Wrote {}", unit_path.display());
135    run_systemctl(systemctl, &["daemon-reload"])?;
136    run_systemctl(systemctl, &["enable", "--now", "orch"])?;
137    println!("Enabled and started the orch user service.");
138    println!("A user service stops at logout unless lingering is on.");
139    println!("  loginctl enable-linger $USER");
140    Ok(())
141}
142
143#[cfg(any(test, not(target_os = "macos")))]
144fn linux_uninstall(unit_dir: &Path, systemctl: &Path) -> anyhow::Result<()> {
145    let unit_path = unit_dir.join("orch.service");
146    if !unit_path.exists() {
147        let _ = run_systemctl(systemctl, &["disable", "--now", "orch"]);
148        println!("No orch user unit at {}", unit_path.display());
149        return Ok(());
150    }
151    run_systemctl(systemctl, &["disable", "--now", "orch"])?;
152    fs::remove_file(&unit_path).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 = 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) = env::var("XDG_CONFIG_HOME")
222        && !xdg.is_empty()
223    {
224        return Ok(PathBuf::from(xdg).join("systemd").join("user"));
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 = 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}