1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub(crate) enum StepOutcome {
23 Succeeded,
25 Failed { code: Option<i32> },
27 SpawnFailed,
29}
30
31impl StepOutcome {
32 pub(crate) fn is_success(self) -> bool {
33 matches!(self, StepOutcome::Succeeded)
34 }
35}
36
37pub(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
46fn 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 spawn_error = match &status {
58 Err(e) => Some(e.to_string()),
59 Ok(_) => None,
60 };
61 let outcome = classify_status(status);
62 match outcome {
63 StepOutcome::Succeeded => {
64 info!(
65 target: TRACE_TARGET,
66 op,
67 step,
68 elapsed_ms,
69 "service step succeeded"
70 );
71 }
72 StepOutcome::Failed { code } => {
73 warn!(
74 target: TRACE_TARGET,
75 op,
76 step,
77 elapsed_ms,
78 exit_code = code,
79 "service step exited non-zero"
80 );
81 }
82 StepOutcome::SpawnFailed => {
83 warn!(
84 target: TRACE_TARGET,
85 op,
86 step,
87 elapsed_ms,
88 error = spawn_error.as_deref().unwrap_or("unknown"),
89 "service step could not be spawned (tool missing on PATH?)"
90 );
91 }
92 }
93 outcome
94}
95
96#[cfg(target_os = "linux")]
97const SERVICE_FILENAME: &str = "minis-studio-worker.service";
98#[cfg(target_os = "macos")]
99const SERVICE_FILENAME: &str = "gg.minis.studio-worker.plist";
100#[cfg(target_os = "windows")]
101const SERVICE_FILENAME: &str = "minis-studio-worker.task.xml";
102
103fn binary_path() -> Result<PathBuf> {
104 std::env::current_exe().context("resolving current executable path")
105}
106
107#[cfg(target_os = "linux")]
108fn default_unit_dir() -> Result<PathBuf> {
109 let dirs =
110 directories::BaseDirs::new().ok_or_else(|| anyhow::anyhow!("cannot resolve user dirs"))?;
111 let path = dirs.config_dir().join("systemd").join("user");
112 std::fs::create_dir_all(&path)?;
113 Ok(path)
114}
115
116#[cfg(target_os = "macos")]
117fn default_unit_dir() -> Result<PathBuf> {
118 let home = std::env::var("HOME").context("HOME not set")?;
119 let path = PathBuf::from(home).join("Library").join("LaunchAgents");
120 std::fs::create_dir_all(&path)?;
121 Ok(path)
122}
123
124#[cfg(target_os = "windows")]
125fn default_unit_dir() -> Result<PathBuf> {
126 let app_data = std::env::var("APPDATA").context("APPDATA not set")?;
127 let path = PathBuf::from(app_data).join("minis-studio-worker");
128 std::fs::create_dir_all(&path)?;
129 Ok(path)
130}
131
132pub trait ServiceOps {
135 fn unit_dir(&self) -> Result<PathBuf>;
136 fn binary_path(&self) -> Result<PathBuf>;
137 fn activate(&self, _unit_path: &Path) -> bool {
142 false
143 }
144 fn deactivate(&self, _unit_path: &Path) {}
145}
146
147pub struct RealOps;
149
150impl ServiceOps for RealOps {
151 fn unit_dir(&self) -> Result<PathBuf> {
152 default_unit_dir()
153 }
154
155 fn binary_path(&self) -> Result<PathBuf> {
156 binary_path()
157 }
158
159 #[allow(unused_variables)]
160 fn activate(&self, unit_path: &Path) -> bool {
161 #[cfg(target_os = "linux")]
162 {
163 let mut reload = Command::new("systemctl");
164 reload.args(["--user", "daemon-reload"]);
165 if !run_step("activate", "daemon-reload", reload).is_success() {
166 return false;
167 }
168 let mut enable = Command::new("systemctl");
169 enable.args(["--user", "enable", "--now", SERVICE_FILENAME]);
170 run_step("activate", "enable-now", enable).is_success()
171 }
172 #[cfg(target_os = "macos")]
173 {
174 let mut load = Command::new("launchctl");
176 load.args(["load", "-w", unit_path.to_string_lossy().as_ref()]);
177 run_step("activate", "launchctl-load", load).is_success()
178 }
179 #[cfg(target_os = "windows")]
180 {
181 let mut create = Command::new("schtasks");
183 create.args([
184 "/Create",
185 "/XML",
186 unit_path.to_string_lossy().as_ref(),
187 "/TN",
188 "MinisStudioWorker",
189 "/F",
190 ]);
191 run_step("activate", "schtasks-create", create).is_success()
192 }
193 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
194 {
195 false
196 }
197 }
198
199 #[allow(unused_variables)]
200 fn deactivate(&self, unit_path: &Path) {
201 #[cfg(target_os = "linux")]
202 {
203 let mut disable = Command::new("systemctl");
204 disable.args(["--user", "disable", "--now", SERVICE_FILENAME]);
205 let _ = run_step("deactivate", "disable-now", disable);
206 }
207 #[cfg(target_os = "macos")]
208 {
209 let mut unload = Command::new("launchctl");
210 unload.args(["unload", unit_path.to_string_lossy().as_ref()]);
211 let _ = run_step("deactivate", "launchctl-unload", unload);
212 }
213 #[cfg(target_os = "windows")]
214 {
215 let mut delete = Command::new("schtasks");
216 delete.args(["/Delete", "/TN", "MinisStudioWorker", "/F"]);
217 let _ = run_step("deactivate", "schtasks-delete", delete);
218 }
219 }
220}
221
222pub fn install(config_path: Option<&str>) -> Result<()> {
223 install_with(&RealOps, config_path)
224}
225
226pub fn setup(config_path: Option<&str>) -> Result<()> {
232 setup_with(&RealOps, config_path)
233}
234
235pub fn setup_summary(machine_name: &str, api_base_url: &str, discovery_path: &str) -> String {
241 let base = api_base_url.trim_end_matches('/');
242 format!(
243 "\nstudio-worker is installed and running.\n\n\
244 Next step — approve this worker in the studio:\n\
245 \u{2022} open {base}/graphics and find this machine in the workers list\n\
246 \u{2022} it appears as: {machine_name}\n\
247 \u{2022} once an admin approves it, the worker starts claiming jobs automatically\n\n\
248 Local image API (no studio needed):\n\
249 \u{2022} URL + bearer token are written to: {discovery_path}\n\
250 \u{2022} POST /image there to generate locally\n\n\
251 Nothing else to do — models and GPU runtimes download on demand.\n"
252 )
253}
254
255pub fn setup_with<O: ServiceOps>(ops: &O, config_path: Option<&str>) -> Result<()> {
256 let (cfg, path) = crate::config::load(config_path)?;
257
258 install_with(ops, config_path)?;
261
262 let discovery = crate::config::local_api_discovery_path_for(&path)
263 .map(|p| p.display().to_string())
264 .unwrap_or_else(|| "<config dir>/local-api.json".to_string());
265 print!(
266 "{}",
267 setup_summary(&crate::sys::machine_name(), &cfg.api_base_url, &discovery)
268 );
269 info!(
270 target: TRACE_TARGET,
271 op = "setup",
272 config_path = %path.display(),
273 "setup completed"
274 );
275 Ok(())
276}
277
278pub fn uninstall() -> Result<()> {
279 uninstall_with(&RealOps)
280}
281
282pub fn install_with<O: ServiceOps>(ops: &O, config_path: Option<&str>) -> Result<()> {
286 let bin = ops.binary_path()?;
287 let cfg_arg = config_path
288 .map(|p| format!("--config {p} "))
289 .unwrap_or_default();
290 let dir = ops.unit_dir()?;
291 let path = dir.join(SERVICE_FILENAME);
292
293 let body = render_service(&bin.display().to_string(), &cfg_arg);
294 std::fs::write(&path, &body)
295 .with_context(|| format!("writing service file {}", path.display()))?;
296
297 println!("wrote service unit: {}", path.display());
298
299 let activated = ops.activate(&path);
300 if activated {
301 println!("activated service unit");
302 } else {
303 print_activation_instructions(&path);
304 }
305 info!(
306 target: TRACE_TARGET,
307 op = "install",
308 unit_path = %path.display(),
309 binary_path = %bin.display(),
310 activated,
311 "service install completed"
312 );
313 Ok(())
314}
315
316pub fn uninstall_with<O: ServiceOps>(ops: &O) -> Result<()> {
317 let dir = ops.unit_dir()?;
318 let path = dir.join(SERVICE_FILENAME);
319 ops.deactivate(&path);
320 let removed = if path.exists() {
321 std::fs::remove_file(&path)?;
322 println!("removed service unit: {}", path.display());
323 true
324 } else {
325 println!("no service unit to remove at {}", path.display());
326 false
327 };
328 info!(
329 target: TRACE_TARGET,
330 op = "uninstall",
331 unit_path = %path.display(),
332 removed,
333 "service uninstall completed"
334 );
335 Ok(())
336}
337
338fn print_activation_instructions(path: &Path) {
339 #[cfg(target_os = "linux")]
340 {
341 println!("activate manually:");
342 println!(" systemctl --user daemon-reload");
343 println!(" systemctl --user enable --now {SERVICE_FILENAME}");
344 let _ = path;
345 }
346 #[cfg(target_os = "macos")]
347 println!("load with: launchctl load -w {}", path.display());
348 #[cfg(target_os = "windows")]
349 println!(
350 "register with: schtasks /Create /XML {} /TN MinisStudioWorker",
351 path.display()
352 );
353 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
354 let _ = path;
355}
356
357#[cfg(target_os = "linux")]
358pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
359 format!(
360 r#"[Unit]
361Description=Minis studio worker (pull-based image-generation agent)
362After=network-online.target
363
364[Service]
365Type=simple
366ExecStart={bin} {cfg_arg}run
367Restart=on-failure
368RestartSec=5
369Environment=RUST_LOG=studio_worker=info
370
371[Install]
372WantedBy=default.target
373"#
374 )
375}
376
377#[cfg(target_os = "macos")]
378pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
379 let cfg_args = cfg_arg.trim();
380 let extra = if cfg_args.is_empty() {
381 String::new()
382 } else {
383 cfg_args
384 .split_whitespace()
385 .map(|s| format!(" <string>{}</string>\n", s))
386 .collect::<String>()
387 };
388 format!(
389 r#"<?xml version="1.0" encoding="UTF-8"?>
390<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
391<plist version="1.0">
392<dict>
393 <key>Label</key><string>gg.minis.studio-worker</string>
394 <key>ProgramArguments</key>
395 <array>
396 <string>{bin}</string>
397{extra} <string>run</string>
398 </array>
399 <key>RunAtLoad</key><true/>
400 <key>KeepAlive</key><true/>
401 <key>EnvironmentVariables</key>
402 <dict><key>RUST_LOG</key><string>studio_worker=info</string></dict>
403</dict>
404</plist>
405"#
406 )
407}
408
409#[cfg(target_os = "windows")]
410pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
411 let args = format!("{cfg_arg}run").trim().to_string();
412 format!(
413 r#"<?xml version="1.0" encoding="UTF-16"?>
414<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
415 <Triggers>
416 <LogonTrigger><Enabled>true</Enabled></LogonTrigger>
417 </Triggers>
418 <Settings>
419 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
420 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
421 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
422 <AllowStartOnDemand>true</AllowStartOnDemand>
423 <Enabled>true</Enabled>
424 <Hidden>false</Hidden>
425 <RestartOnFailure>
426 <Interval>PT1M</Interval>
427 <Count>10</Count>
428 </RestartOnFailure>
429 </Settings>
430 <Actions>
431 <Exec>
432 <Command>{bin}</Command>
433 <Arguments>{args}</Arguments>
434 </Exec>
435 </Actions>
436</Task>
437"#
438 )
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use std::cell::RefCell;
445 use std::path::PathBuf;
446 use tempfile::tempdir;
447
448 struct FakeOps {
449 bin: PathBuf,
450 dir: PathBuf,
451 activate_returns: bool,
452 activate_calls: RefCell<Vec<PathBuf>>,
453 deactivate_calls: RefCell<Vec<PathBuf>>,
454 }
455
456 impl ServiceOps for FakeOps {
457 fn unit_dir(&self) -> Result<PathBuf> {
458 Ok(self.dir.clone())
459 }
460 fn binary_path(&self) -> Result<PathBuf> {
461 Ok(self.bin.clone())
462 }
463 fn activate(&self, unit_path: &Path) -> bool {
464 self.activate_calls
465 .borrow_mut()
466 .push(unit_path.to_path_buf());
467 self.activate_returns
468 }
469 fn deactivate(&self, unit_path: &Path) {
470 self.deactivate_calls
471 .borrow_mut()
472 .push(unit_path.to_path_buf());
473 }
474 }
475
476 #[cfg(target_os = "linux")]
477 #[test]
478 fn linux_render_includes_exec_start_and_install_section() {
479 let rendered = render_service("/usr/bin/studio-worker", "");
480 assert!(rendered.contains("ExecStart=/usr/bin/studio-worker run"));
481 assert!(rendered.contains("[Install]"));
482 assert!(rendered.contains("Restart=on-failure"));
483 }
484
485 #[cfg(target_os = "linux")]
486 #[test]
487 fn linux_render_passes_config_arg() {
488 let rendered = render_service("/usr/bin/studio-worker", "--config /etc/conf.toml ");
489 assert!(rendered.contains("--config /etc/conf.toml run"));
490 }
491
492 #[cfg(target_os = "macos")]
493 #[test]
494 fn macos_render_emits_valid_plist_xml() {
495 let rendered = render_service("/usr/local/bin/studio-worker", "");
496 assert!(rendered.contains("<plist version=\"1.0\">"));
497 assert!(rendered.contains("<string>/usr/local/bin/studio-worker</string>"));
498 }
499
500 #[cfg(target_os = "macos")]
501 #[test]
502 fn macos_render_includes_config_args_when_provided() {
503 let rendered = render_service("/usr/local/bin/studio-worker", "--config /etc/conf.toml ");
504 assert!(rendered.contains("<string>--config</string>"));
505 assert!(rendered.contains("<string>/etc/conf.toml</string>"));
506 }
507
508 #[cfg(target_os = "windows")]
509 #[test]
510 fn windows_render_emits_valid_task_xml() {
511 let rendered = render_service("C:\\worker.exe", "");
512 assert!(rendered.contains("<Command>C:\\worker.exe</Command>"));
513 assert!(rendered.contains("<Arguments>run</Arguments>"));
514 }
515
516 #[test]
517 fn install_with_writes_unit_file_and_succeeds_when_activate_returns_true() {
518 let dir = tempdir().unwrap();
519 let ops = FakeOps {
520 bin: PathBuf::from("/usr/bin/studio-worker"),
521 dir: dir.path().to_path_buf(),
522 activate_returns: true,
523 activate_calls: RefCell::new(Vec::new()),
524 deactivate_calls: RefCell::new(Vec::new()),
525 };
526 install_with(&ops, Some("/etc/conf.toml")).unwrap();
527 let written = dir.path().join(SERVICE_FILENAME);
528 assert!(
529 written.exists(),
530 "unit file should exist at {}",
531 written.display()
532 );
533 let body = std::fs::read_to_string(&written).unwrap();
534 assert!(body.contains("studio-worker"));
535 assert_eq!(ops.activate_calls.borrow().len(), 1);
536 assert_eq!(ops.activate_calls.borrow()[0], written);
537 }
538
539 #[test]
540 fn install_with_falls_back_to_manual_instructions_when_activate_fails() {
541 let dir = tempdir().unwrap();
542 let ops = FakeOps {
543 bin: PathBuf::from("/usr/bin/studio-worker"),
544 dir: dir.path().to_path_buf(),
545 activate_returns: false,
546 activate_calls: RefCell::new(Vec::new()),
547 deactivate_calls: RefCell::new(Vec::new()),
548 };
549 install_with(&ops, None).unwrap();
550 assert!(dir.path().join(SERVICE_FILENAME).exists());
551 }
552
553 #[test]
554 fn uninstall_with_removes_file_and_calls_deactivate() {
555 let dir = tempdir().unwrap();
556 let path = dir.path().join(SERVICE_FILENAME);
557 std::fs::write(&path, "dummy").unwrap();
558 let ops = FakeOps {
559 bin: PathBuf::from("/usr/bin/studio-worker"),
560 dir: dir.path().to_path_buf(),
561 activate_returns: false,
562 activate_calls: RefCell::new(Vec::new()),
563 deactivate_calls: RefCell::new(Vec::new()),
564 };
565 uninstall_with(&ops).unwrap();
566 assert!(!path.exists());
567 assert_eq!(ops.deactivate_calls.borrow().len(), 1);
568 }
569
570 #[test]
571 fn uninstall_with_is_idempotent_when_file_missing() {
572 let dir = tempdir().unwrap();
573 let ops = FakeOps {
574 bin: PathBuf::from("/usr/bin/studio-worker"),
575 dir: dir.path().to_path_buf(),
576 activate_returns: false,
577 activate_calls: RefCell::new(Vec::new()),
578 deactivate_calls: RefCell::new(Vec::new()),
579 };
580 uninstall_with(&ops).unwrap();
582 }
583
584 use crate::test_support::capture;
598
599 fn fake_ops(dir: PathBuf, activate_returns: bool) -> FakeOps {
600 FakeOps {
601 bin: PathBuf::from("/usr/bin/studio-worker"),
602 dir,
603 activate_returns,
604 activate_calls: RefCell::new(Vec::new()),
605 deactivate_calls: RefCell::new(Vec::new()),
606 }
607 }
608
609 #[test]
610 fn install_with_emits_info_event_with_activated_true_when_activation_succeeds() {
611 let dir = tempdir().unwrap();
612 let dir_path = dir.path().to_path_buf();
613 let logs = capture(move || {
614 install_with(&fake_ops(dir_path, true), None).unwrap();
615 });
616 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
617 assert!(
618 logs.contains("studio_worker::service"),
619 "expected service target, got: {logs}"
620 );
621 assert!(logs.contains("op=\"install\""), "expected op field: {logs}");
622 assert!(
623 logs.contains("activated=true"),
624 "expected activated=true: {logs}"
625 );
626 assert!(
627 logs.contains(SERVICE_FILENAME),
628 "expected unit_path in log, got: {logs}"
629 );
630 }
631
632 #[test]
633 fn install_with_emits_info_event_with_activated_false_on_manual_fallback() {
634 let dir = tempdir().unwrap();
635 let dir_path = dir.path().to_path_buf();
636 let logs = capture(move || {
637 install_with(&fake_ops(dir_path, false), None).unwrap();
638 });
639 assert!(
640 logs.contains("activated=false"),
641 "expected activated=false: {logs}"
642 );
643 }
644
645 #[test]
646 fn uninstall_with_emits_info_event_with_removed_true_when_file_existed() {
647 let dir = tempdir().unwrap();
648 let path = dir.path().join(SERVICE_FILENAME);
649 std::fs::write(&path, "dummy").unwrap();
650 let dir_path = dir.path().to_path_buf();
651 let logs = capture(move || {
652 uninstall_with(&fake_ops(dir_path, false)).unwrap();
653 });
654 assert!(
655 logs.contains("op=\"uninstall\""),
656 "expected op field: {logs}"
657 );
658 assert!(
659 logs.contains("removed=true"),
660 "expected removed=true: {logs}"
661 );
662 }
663
664 #[test]
665 fn uninstall_with_emits_info_event_with_removed_false_when_file_missing() {
666 let dir = tempdir().unwrap();
667 let dir_path = dir.path().to_path_buf();
668 let logs = capture(move || {
669 uninstall_with(&fake_ops(dir_path, false)).unwrap();
670 });
671 assert!(
672 logs.contains("removed=false"),
673 "expected removed=false: {logs}"
674 );
675 }
676
677 #[test]
684 fn classify_status_recognises_zero_exit_as_succeeded() {
685 let status = std::process::Command::new(std::env::current_exe().unwrap())
688 .arg("--list")
689 .stdout(std::process::Stdio::null())
690 .status();
691 assert_eq!(classify_status(status), StepOutcome::Succeeded);
692 }
693
694 #[test]
695 fn classify_status_recognises_non_zero_exit_as_failed() {
696 let status = std::process::Command::new(std::env::current_exe().unwrap())
699 .arg("--definitely-not-a-real-flag-zzzqx")
700 .stdout(std::process::Stdio::null())
701 .stderr(std::process::Stdio::null())
702 .status();
703 match classify_status(status) {
704 StepOutcome::Failed { .. } => {}
705 other => panic!("expected Failed, got {other:?}"),
706 }
707 }
708
709 #[test]
710 fn classify_status_recognises_spawn_failure() {
711 let status =
712 std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker").status();
713 assert_eq!(classify_status(status), StepOutcome::SpawnFailed);
714 }
715
716 #[test]
717 fn run_step_spawn_failure_logs_underlying_io_error() {
718 let cmd = std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker");
723 let logs = capture(move || {
724 assert_eq!(run_step("activate", "smoke", cmd), StepOutcome::SpawnFailed);
725 });
726 assert!(logs.contains("WARN"), "expected WARN event, got: {logs}");
727 assert!(
728 logs.contains("could not be spawned"),
729 "expected spawn-failure message, got: {logs}"
730 );
731 assert!(
732 logs.contains("error="),
733 "expected structured error field, got: {logs}"
734 );
735 let lower = logs.to_lowercase();
740 assert!(
741 lower.contains("os error")
742 || lower.contains("not found")
743 || lower.contains("cannot find"),
744 "expected the underlying io::Error text, got: {logs}"
745 );
746 }
747
748 #[test]
749 fn step_outcome_is_success_only_for_succeeded() {
750 assert!(StepOutcome::Succeeded.is_success());
751 assert!(!StepOutcome::Failed { code: Some(1) }.is_success());
752 assert!(!StepOutcome::Failed { code: None }.is_success());
753 assert!(!StepOutcome::SpawnFailed.is_success());
754 }
755
756 #[test]
757 fn setup_summary_names_machine_studio_and_discovery() {
758 let s = setup_summary(
759 "alices-rig",
760 "https://studio.minis.gg/",
761 "/home/alice/.config/minis-studio-worker/local-api.json",
762 );
763 assert!(s.contains("alices-rig"), "must name the machine: {s}");
765 assert!(s.contains("https://studio.minis.gg/graphics"), "got: {s}");
767 assert!(
768 !s.contains(".gg//graphics"),
769 "trailing slash not trimmed: {s}"
770 );
771 assert!(
773 s.contains("local-api.json"),
774 "must point at discovery file: {s}"
775 );
776 assert!(s.contains("download on demand"), "got: {s}");
778 }
779
780 #[test]
781 fn setup_with_installs_the_service_and_prints_guidance() {
782 let cfgdir = tempdir().unwrap();
783 let cfg_path = cfgdir.path().join("config.toml");
784 crate::config::save(&crate::config::Config::default(), &cfg_path).unwrap();
785
786 let unitdir = tempdir().unwrap();
787 let ops = fake_ops(unitdir.path().to_path_buf(), true);
788 let cfg_arg = cfg_path.to_string_lossy().to_string();
789 let logs = capture({
790 let cfg_arg = cfg_arg.clone();
791 move || {
792 setup_with(&ops, Some(&cfg_arg)).unwrap();
793 }
794 });
795 assert!(
797 logs.contains("op=\"setup\""),
798 "expected setup event: {logs}"
799 );
800 assert!(unitdir.path().join(SERVICE_FILENAME).exists());
802 }
803}