Skip to main content

studio_worker/
service.rs

1//! OS service install/uninstall.  Linux: systemd --user.  macOS: launchd
2//! plist template (written but not loaded — operator runs `launchctl load`).
3//! Windows: schtasks template (written but not registered — operator runs
4//! the printed command).
5//!
6//! All system side-effects (Command::status, fs writes) flow through the
7//! `ServiceOps` trait so the public install/uninstall functions can be
8//! unit-tested without touching the real OS.
9use anyhow::{anyhow, Context, Result};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use tracing::{info, warn};
13
14const TRACE_TARGET: &str = "studio_worker::service";
15
16/// Outcome of running a single OS command step (e.g. `systemctl
17/// daemon-reload`, `launchctl unload`, `schtasks /Delete`).  Splits the
18/// three observable states so callers can compose them into an overall
19/// activation/deactivation success and so each one emits a distinct
20/// structured tracing event instead of being silently swallowed.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub(crate) enum StepOutcome {
23    /// Command spawned and exited with a zero status.
24    Succeeded,
25    /// Command spawned but exited non-zero (with the captured code if any).
26    Failed { code: Option<i32> },
27    /// Spawn itself failed — tool missing on PATH, permission denied, etc.
28    SpawnFailed,
29}
30
31impl StepOutcome {
32    pub(crate) fn is_success(self) -> bool {
33        matches!(self, StepOutcome::Succeeded)
34    }
35}
36
37/// Pure mapping from a `Command::status()` result onto [`StepOutcome`].
38pub(crate) fn classify_status(status: std::io::Result<std::process::ExitStatus>) -> StepOutcome {
39    match status {
40        Ok(s) if s.success() => StepOutcome::Succeeded,
41        Ok(s) => StepOutcome::Failed { code: s.code() },
42        Err(_) => StepOutcome::SpawnFailed,
43    }
44}
45
46/// Run a single command step and emit a structured tracing event for
47/// the outcome.  Returns the classified [`StepOutcome`] so callers can
48/// chain steps and short-circuit on failure.  Without this every
49/// `RealOps::activate` / `deactivate` step would silently swallow
50/// failures of systemctl / launchctl / schtasks.
51fn run_step(op: &'static str, step: &'static str, mut cmd: Command) -> StepOutcome {
52    let started = std::time::Instant::now();
53    let status = cmd.status();
54    let elapsed_ms = started.elapsed().as_millis() as u64;
55    let outcome = classify_status(status);
56    match outcome {
57        StepOutcome::Succeeded => {
58            info!(
59                target: TRACE_TARGET,
60                op,
61                step,
62                elapsed_ms,
63                "service step succeeded"
64            );
65        }
66        StepOutcome::Failed { code } => {
67            warn!(
68                target: TRACE_TARGET,
69                op,
70                step,
71                elapsed_ms,
72                exit_code = code,
73                "service step exited non-zero"
74            );
75        }
76        StepOutcome::SpawnFailed => {
77            warn!(
78                target: TRACE_TARGET,
79                op,
80                step,
81                elapsed_ms,
82                "service step could not be spawned (tool missing on PATH?)"
83            );
84        }
85    }
86    outcome
87}
88
89#[cfg(target_os = "linux")]
90const SERVICE_FILENAME: &str = "minis-studio-worker.service";
91#[cfg(target_os = "macos")]
92const SERVICE_FILENAME: &str = "gg.minis.studio-worker.plist";
93#[cfg(target_os = "windows")]
94const SERVICE_FILENAME: &str = "minis-studio-worker.task.xml";
95
96fn binary_path() -> Result<PathBuf> {
97    std::env::current_exe().context("resolving current executable path")
98}
99
100#[cfg(target_os = "linux")]
101fn default_unit_dir() -> Result<PathBuf> {
102    let dirs = directories::BaseDirs::new().ok_or_else(|| anyhow!("cannot resolve user dirs"))?;
103    let path = dirs.config_dir().join("systemd").join("user");
104    std::fs::create_dir_all(&path)?;
105    Ok(path)
106}
107
108#[cfg(target_os = "macos")]
109fn default_unit_dir() -> Result<PathBuf> {
110    let home = std::env::var("HOME").context("HOME not set")?;
111    let path = PathBuf::from(home).join("Library").join("LaunchAgents");
112    std::fs::create_dir_all(&path)?;
113    Ok(path)
114}
115
116#[cfg(target_os = "windows")]
117fn default_unit_dir() -> Result<PathBuf> {
118    let app_data = std::env::var("APPDATA").context("APPDATA not set")?;
119    let path = PathBuf::from(app_data).join("minis-studio-worker");
120    std::fs::create_dir_all(&path)?;
121    Ok(path)
122}
123
124/// Abstraction over the side-effecting parts of install/uninstall so the
125/// install logic itself is fully unit-testable.
126pub trait ServiceOps {
127    fn unit_dir(&self) -> Result<PathBuf>;
128    fn binary_path(&self) -> Result<PathBuf>;
129    /// Activate the unit (systemctl --user enable / launchctl load /
130    /// schtasks /Create).  Implementations return false if the platform
131    /// tool isn't available so install() can still succeed (file
132    /// written, manual activation instructions printed).
133    fn activate(&self, _unit_path: &Path) -> bool {
134        false
135    }
136    fn deactivate(&self, _unit_path: &Path) {}
137}
138
139/// Real, system-touching implementation used by the CLI.
140pub struct RealOps;
141
142impl ServiceOps for RealOps {
143    fn unit_dir(&self) -> Result<PathBuf> {
144        default_unit_dir()
145    }
146
147    fn binary_path(&self) -> Result<PathBuf> {
148        binary_path()
149    }
150
151    #[allow(unused_variables)]
152    fn activate(&self, unit_path: &Path) -> bool {
153        #[cfg(target_os = "linux")]
154        {
155            let mut reload = Command::new("systemctl");
156            reload.args(["--user", "daemon-reload"]);
157            if !run_step("activate", "daemon-reload", reload).is_success() {
158                return false;
159            }
160            let mut enable = Command::new("systemctl");
161            enable.args(["--user", "enable", "--now", SERVICE_FILENAME]);
162            run_step("activate", "enable-now", enable).is_success()
163        }
164        #[cfg(target_os = "macos")]
165        {
166            // Load the LaunchAgent so it runs now and at every login.
167            let mut load = Command::new("launchctl");
168            load.args(["load", "-w", unit_path.to_string_lossy().as_ref()]);
169            run_step("activate", "launchctl-load", load).is_success()
170        }
171        #[cfg(target_os = "windows")]
172        {
173            // Register the scheduled task from the XML we just wrote.
174            let mut create = Command::new("schtasks");
175            create.args([
176                "/Create",
177                "/XML",
178                unit_path.to_string_lossy().as_ref(),
179                "/TN",
180                "MinisStudioWorker",
181                "/F",
182            ]);
183            run_step("activate", "schtasks-create", create).is_success()
184        }
185        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
186        {
187            false
188        }
189    }
190
191    #[allow(unused_variables)]
192    fn deactivate(&self, unit_path: &Path) {
193        #[cfg(target_os = "linux")]
194        {
195            let mut disable = Command::new("systemctl");
196            disable.args(["--user", "disable", "--now", SERVICE_FILENAME]);
197            let _ = run_step("deactivate", "disable-now", disable);
198        }
199        #[cfg(target_os = "macos")]
200        {
201            let mut unload = Command::new("launchctl");
202            unload.args(["unload", unit_path.to_string_lossy().as_ref()]);
203            let _ = run_step("deactivate", "launchctl-unload", unload);
204        }
205        #[cfg(target_os = "windows")]
206        {
207            let mut delete = Command::new("schtasks");
208            delete.args(["/Delete", "/TN", "MinisStudioWorker", "/F"]);
209            let _ = run_step("deactivate", "schtasks-delete", delete);
210        }
211    }
212}
213
214pub fn install(config_path: Option<&str>) -> Result<()> {
215    install_with(&RealOps, config_path)
216}
217
218pub fn uninstall() -> Result<()> {
219    uninstall_with(&RealOps)
220}
221
222/// Write the unit file using the supplied ops and print manual activation
223/// instructions if the platform tool isn't available.  Public-but-`pub`
224/// so tests in `tests/` can drive it with a fake ops.
225pub fn install_with<O: ServiceOps>(ops: &O, config_path: Option<&str>) -> Result<()> {
226    let bin = ops.binary_path()?;
227    let cfg_arg = config_path
228        .map(|p| format!("--config {p} "))
229        .unwrap_or_default();
230    let dir = ops.unit_dir()?;
231    let path = dir.join(SERVICE_FILENAME);
232
233    let body = render_service(&bin.display().to_string(), &cfg_arg);
234    std::fs::write(&path, &body)
235        .with_context(|| format!("writing service file {}", path.display()))?;
236
237    println!("wrote service unit: {}", path.display());
238
239    let activated = ops.activate(&path);
240    if activated {
241        println!("activated service unit");
242    } else {
243        print_activation_instructions(&path);
244    }
245    info!(
246        target: TRACE_TARGET,
247        op = "install",
248        unit_path = %path.display(),
249        binary_path = %bin.display(),
250        activated,
251        "service install completed"
252    );
253    Ok(())
254}
255
256pub fn uninstall_with<O: ServiceOps>(ops: &O) -> Result<()> {
257    let dir = ops.unit_dir()?;
258    let path = dir.join(SERVICE_FILENAME);
259    ops.deactivate(&path);
260    let removed = if path.exists() {
261        std::fs::remove_file(&path)?;
262        println!("removed service unit: {}", path.display());
263        true
264    } else {
265        println!("no service unit to remove at {}", path.display());
266        false
267    };
268    info!(
269        target: TRACE_TARGET,
270        op = "uninstall",
271        unit_path = %path.display(),
272        removed,
273        "service uninstall completed"
274    );
275    Ok(())
276}
277
278fn print_activation_instructions(path: &Path) {
279    #[cfg(target_os = "linux")]
280    {
281        println!("activate manually:");
282        println!("  systemctl --user daemon-reload");
283        println!("  systemctl --user enable --now {SERVICE_FILENAME}");
284        let _ = path;
285    }
286    #[cfg(target_os = "macos")]
287    println!("load with: launchctl load -w {}", path.display());
288    #[cfg(target_os = "windows")]
289    println!(
290        "register with: schtasks /Create /XML {} /TN MinisStudioWorker",
291        path.display()
292    );
293    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
294    let _ = path;
295}
296
297#[cfg(target_os = "linux")]
298pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
299    format!(
300        r#"[Unit]
301Description=Minis studio worker (pull-based image-generation agent)
302After=network-online.target
303
304[Service]
305Type=simple
306ExecStart={bin} {cfg_arg}run
307Restart=on-failure
308RestartSec=5
309Environment=RUST_LOG=studio_worker=info
310
311[Install]
312WantedBy=default.target
313"#
314    )
315}
316
317#[cfg(target_os = "macos")]
318pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
319    let cfg_args = cfg_arg.trim();
320    let extra = if cfg_args.is_empty() {
321        String::new()
322    } else {
323        cfg_args
324            .split_whitespace()
325            .map(|s| format!("    <string>{}</string>\n", s))
326            .collect::<String>()
327    };
328    format!(
329        r#"<?xml version="1.0" encoding="UTF-8"?>
330<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
331<plist version="1.0">
332<dict>
333  <key>Label</key><string>gg.minis.studio-worker</string>
334  <key>ProgramArguments</key>
335  <array>
336    <string>{bin}</string>
337{extra}    <string>run</string>
338  </array>
339  <key>RunAtLoad</key><true/>
340  <key>KeepAlive</key><true/>
341  <key>EnvironmentVariables</key>
342  <dict><key>RUST_LOG</key><string>studio_worker=info</string></dict>
343</dict>
344</plist>
345"#
346    )
347}
348
349#[cfg(target_os = "windows")]
350pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
351    let args = format!("{cfg_arg}run").trim().to_string();
352    format!(
353        r#"<?xml version="1.0" encoding="UTF-16"?>
354<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
355  <Triggers>
356    <LogonTrigger><Enabled>true</Enabled></LogonTrigger>
357  </Triggers>
358  <Settings>
359    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
360    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
361    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
362    <AllowStartOnDemand>true</AllowStartOnDemand>
363    <Enabled>true</Enabled>
364    <Hidden>false</Hidden>
365    <RestartOnFailure>
366      <Interval>PT1M</Interval>
367      <Count>10</Count>
368    </RestartOnFailure>
369  </Settings>
370  <Actions>
371    <Exec>
372      <Command>{bin}</Command>
373      <Arguments>{args}</Arguments>
374    </Exec>
375  </Actions>
376</Task>
377"#
378    )
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use std::cell::RefCell;
385    use std::path::PathBuf;
386    use tempfile::tempdir;
387
388    struct FakeOps {
389        bin: PathBuf,
390        dir: PathBuf,
391        activate_returns: bool,
392        activate_calls: RefCell<Vec<PathBuf>>,
393        deactivate_calls: RefCell<Vec<PathBuf>>,
394    }
395
396    impl ServiceOps for FakeOps {
397        fn unit_dir(&self) -> Result<PathBuf> {
398            Ok(self.dir.clone())
399        }
400        fn binary_path(&self) -> Result<PathBuf> {
401            Ok(self.bin.clone())
402        }
403        fn activate(&self, unit_path: &Path) -> bool {
404            self.activate_calls
405                .borrow_mut()
406                .push(unit_path.to_path_buf());
407            self.activate_returns
408        }
409        fn deactivate(&self, unit_path: &Path) {
410            self.deactivate_calls
411                .borrow_mut()
412                .push(unit_path.to_path_buf());
413        }
414    }
415
416    #[cfg(target_os = "linux")]
417    #[test]
418    fn linux_render_includes_exec_start_and_install_section() {
419        let rendered = render_service("/usr/bin/studio-worker", "");
420        assert!(rendered.contains("ExecStart=/usr/bin/studio-worker run"));
421        assert!(rendered.contains("[Install]"));
422        assert!(rendered.contains("Restart=on-failure"));
423    }
424
425    #[cfg(target_os = "linux")]
426    #[test]
427    fn linux_render_passes_config_arg() {
428        let rendered = render_service("/usr/bin/studio-worker", "--config /etc/conf.toml ");
429        assert!(rendered.contains("--config /etc/conf.toml run"));
430    }
431
432    #[cfg(target_os = "macos")]
433    #[test]
434    fn macos_render_emits_valid_plist_xml() {
435        let rendered = render_service("/usr/local/bin/studio-worker", "");
436        assert!(rendered.contains("<plist version=\"1.0\">"));
437        assert!(rendered.contains("<string>/usr/local/bin/studio-worker</string>"));
438    }
439
440    #[cfg(target_os = "macos")]
441    #[test]
442    fn macos_render_includes_config_args_when_provided() {
443        let rendered = render_service("/usr/local/bin/studio-worker", "--config /etc/conf.toml ");
444        assert!(rendered.contains("<string>--config</string>"));
445        assert!(rendered.contains("<string>/etc/conf.toml</string>"));
446    }
447
448    #[cfg(target_os = "windows")]
449    #[test]
450    fn windows_render_emits_valid_task_xml() {
451        let rendered = render_service("C:\\worker.exe", "");
452        assert!(rendered.contains("<Command>C:\\worker.exe</Command>"));
453        assert!(rendered.contains("<Arguments>run</Arguments>"));
454    }
455
456    #[test]
457    fn install_with_writes_unit_file_and_succeeds_when_activate_returns_true() {
458        let dir = tempdir().unwrap();
459        let ops = FakeOps {
460            bin: PathBuf::from("/usr/bin/studio-worker"),
461            dir: dir.path().to_path_buf(),
462            activate_returns: true,
463            activate_calls: RefCell::new(Vec::new()),
464            deactivate_calls: RefCell::new(Vec::new()),
465        };
466        install_with(&ops, Some("/etc/conf.toml")).unwrap();
467        let written = dir.path().join(SERVICE_FILENAME);
468        assert!(
469            written.exists(),
470            "unit file should exist at {}",
471            written.display()
472        );
473        let body = std::fs::read_to_string(&written).unwrap();
474        assert!(body.contains("studio-worker"));
475        assert_eq!(ops.activate_calls.borrow().len(), 1);
476        assert_eq!(ops.activate_calls.borrow()[0], written);
477    }
478
479    #[test]
480    fn install_with_falls_back_to_manual_instructions_when_activate_fails() {
481        let dir = tempdir().unwrap();
482        let ops = FakeOps {
483            bin: PathBuf::from("/usr/bin/studio-worker"),
484            dir: dir.path().to_path_buf(),
485            activate_returns: false,
486            activate_calls: RefCell::new(Vec::new()),
487            deactivate_calls: RefCell::new(Vec::new()),
488        };
489        install_with(&ops, None).unwrap();
490        assert!(dir.path().join(SERVICE_FILENAME).exists());
491    }
492
493    #[test]
494    fn uninstall_with_removes_file_and_calls_deactivate() {
495        let dir = tempdir().unwrap();
496        let path = dir.path().join(SERVICE_FILENAME);
497        std::fs::write(&path, "dummy").unwrap();
498        let ops = FakeOps {
499            bin: PathBuf::from("/usr/bin/studio-worker"),
500            dir: dir.path().to_path_buf(),
501            activate_returns: false,
502            activate_calls: RefCell::new(Vec::new()),
503            deactivate_calls: RefCell::new(Vec::new()),
504        };
505        uninstall_with(&ops).unwrap();
506        assert!(!path.exists());
507        assert_eq!(ops.deactivate_calls.borrow().len(), 1);
508    }
509
510    #[test]
511    fn uninstall_with_is_idempotent_when_file_missing() {
512        let dir = tempdir().unwrap();
513        let ops = FakeOps {
514            bin: PathBuf::from("/usr/bin/studio-worker"),
515            dir: dir.path().to_path_buf(),
516            activate_returns: false,
517            activate_calls: RefCell::new(Vec::new()),
518            deactivate_calls: RefCell::new(Vec::new()),
519        };
520        // No file written; uninstall should still succeed.
521        uninstall_with(&ops).unwrap();
522    }
523
524    // -----------------------------------------------------------------
525    // Structured tracing — proves install/uninstall and the per-platform
526    // RealOps steps emit operator-visible breadcrumbs.  Without these,
527    // a failing `systemctl enable --now`, `launchctl unload` or
528    // `schtasks /Delete` would silently leave the worker un-activated
529    // (or still registered) with no log trail to diagnose it.
530    //
531    // The shared `test_support::capture` helper installs one
532    // process-global subscriber + thread-local sink — see that module
533    // for the why (it dodges the tracing callsite-cache flake we used
534    // to hit with `with_default`).
535    // -----------------------------------------------------------------
536
537    use crate::test_support::capture;
538
539    fn fake_ops(dir: PathBuf, activate_returns: bool) -> FakeOps {
540        FakeOps {
541            bin: PathBuf::from("/usr/bin/studio-worker"),
542            dir,
543            activate_returns,
544            activate_calls: RefCell::new(Vec::new()),
545            deactivate_calls: RefCell::new(Vec::new()),
546        }
547    }
548
549    #[test]
550    fn install_with_emits_info_event_with_activated_true_when_activation_succeeds() {
551        let dir = tempdir().unwrap();
552        let dir_path = dir.path().to_path_buf();
553        let logs = capture(move || {
554            install_with(&fake_ops(dir_path, true), None).unwrap();
555        });
556        assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
557        assert!(
558            logs.contains("studio_worker::service"),
559            "expected service target, got: {logs}"
560        );
561        assert!(logs.contains("op=\"install\""), "expected op field: {logs}");
562        assert!(
563            logs.contains("activated=true"),
564            "expected activated=true: {logs}"
565        );
566        assert!(
567            logs.contains(SERVICE_FILENAME),
568            "expected unit_path in log, got: {logs}"
569        );
570    }
571
572    #[test]
573    fn install_with_emits_info_event_with_activated_false_on_manual_fallback() {
574        let dir = tempdir().unwrap();
575        let dir_path = dir.path().to_path_buf();
576        let logs = capture(move || {
577            install_with(&fake_ops(dir_path, false), None).unwrap();
578        });
579        assert!(
580            logs.contains("activated=false"),
581            "expected activated=false: {logs}"
582        );
583    }
584
585    #[test]
586    fn uninstall_with_emits_info_event_with_removed_true_when_file_existed() {
587        let dir = tempdir().unwrap();
588        let path = dir.path().join(SERVICE_FILENAME);
589        std::fs::write(&path, "dummy").unwrap();
590        let dir_path = dir.path().to_path_buf();
591        let logs = capture(move || {
592            uninstall_with(&fake_ops(dir_path, false)).unwrap();
593        });
594        assert!(
595            logs.contains("op=\"uninstall\""),
596            "expected op field: {logs}"
597        );
598        assert!(
599            logs.contains("removed=true"),
600            "expected removed=true: {logs}"
601        );
602    }
603
604    #[test]
605    fn uninstall_with_emits_info_event_with_removed_false_when_file_missing() {
606        let dir = tempdir().unwrap();
607        let dir_path = dir.path().to_path_buf();
608        let logs = capture(move || {
609            uninstall_with(&fake_ops(dir_path, false)).unwrap();
610        });
611        assert!(
612            logs.contains("removed=false"),
613            "expected removed=false: {logs}"
614        );
615    }
616
617    // -----------------------------------------------------------------
618    // StepOutcome — pure helper that classifies the outcome of an OS
619    // command spawned by RealOps so install/uninstall never silently
620    // swallow non-zero exits or missing tools.
621    // -----------------------------------------------------------------
622
623    #[test]
624    fn classify_status_recognises_zero_exit_as_succeeded() {
625        // Spawn the running test binary with `--list` (the cargo test
626        // harness accepts it and exits 0).
627        let status = std::process::Command::new(std::env::current_exe().unwrap())
628            .arg("--list")
629            .stdout(std::process::Stdio::null())
630            .status();
631        assert_eq!(classify_status(status), StepOutcome::Succeeded);
632    }
633
634    #[test]
635    fn classify_status_recognises_non_zero_exit_as_failed() {
636        // The cargo test harness rejects an unknown long flag with a
637        // non-zero exit.
638        let status = std::process::Command::new(std::env::current_exe().unwrap())
639            .arg("--definitely-not-a-real-flag-zzzqx")
640            .stdout(std::process::Stdio::null())
641            .stderr(std::process::Stdio::null())
642            .status();
643        match classify_status(status) {
644            StepOutcome::Failed { .. } => {}
645            other => panic!("expected Failed, got {other:?}"),
646        }
647    }
648
649    #[test]
650    fn classify_status_recognises_spawn_failure() {
651        let status =
652            std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker").status();
653        assert_eq!(classify_status(status), StepOutcome::SpawnFailed);
654    }
655
656    #[test]
657    fn step_outcome_is_success_only_for_succeeded() {
658        assert!(StepOutcome::Succeeded.is_success());
659        assert!(!StepOutcome::Failed { code: Some(1) }.is_success());
660        assert!(!StepOutcome::Failed { code: None }.is_success());
661        assert!(!StepOutcome::SpawnFailed.is_success());
662    }
663}