243 lines
8.6 KiB
Rust
243 lines
8.6 KiB
Rust
//! The service commands against the platform's service manager, end to end (T044, FR-039h).
|
|
//!
|
|
//! The binary runs with `PATH` holding nothing but a stand-in for `launchctl` or `systemctl`, so
|
|
//! the real one cannot be reached and nothing is registered with the machine. The stand-in keeps
|
|
//! its state in files and starts the daemon itself, as the real one would, so `service start`
|
|
//! has a daemon to wait for. Everything else a user would have is the real code: the definition
|
|
//! written under a scratch home, the status read back from it, and the configuration beside it.
|
|
|
|
#![cfg(any(target_os = "macos", target_os = "linux"))]
|
|
#![allow(
|
|
clippy::expect_used,
|
|
clippy::indexing_slicing,
|
|
clippy::panic,
|
|
clippy::unwrap_used
|
|
)]
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
/// A stand-in `launchctl`, answering the subcommands the launchd backend uses.
|
|
#[cfg(target_os = "macos")]
|
|
const TOOL: (&str, &str) = (
|
|
"launchctl",
|
|
r#"#!/bin/sh
|
|
echo "$*" >> "$FAKE_STATE/calls"
|
|
running() { [ -f "$FAKE_STATE/pid" ] && kill -0 "$(/bin/cat "$FAKE_STATE/pid")" 2>/dev/null; }
|
|
halt() {
|
|
if running; then
|
|
kill "$(/bin/cat "$FAKE_STATE/pid")"
|
|
while running; do /bin/sleep 0.1; done
|
|
fi
|
|
/bin/rm -f "$FAKE_STATE/pid"
|
|
}
|
|
case "$1" in
|
|
bootstrap) : > "$FAKE_STATE/loaded" ;;
|
|
bootout) [ -f "$FAKE_STATE/loaded" ] || { echo "Boot-out failed: 3: No such process" >&2; exit 3; }
|
|
halt; /bin/rm -f "$FAKE_STATE/loaded" ;;
|
|
kickstart) [ -f "$FAKE_STATE/loaded" ] || exit 113
|
|
"$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 &
|
|
echo $! > "$FAKE_STATE/pid" ;;
|
|
kill) halt ;;
|
|
print) [ -f "$FAKE_STATE/loaded" ] || exit 113
|
|
if running; then echo "state = running"; else echo "state = not running"; fi ;;
|
|
*) exit 64 ;;
|
|
esac
|
|
"#,
|
|
);
|
|
|
|
/// A stand-in `systemctl`, answering the `--user` subcommands the systemd backend uses.
|
|
#[cfg(target_os = "linux")]
|
|
const TOOL: (&str, &str) = (
|
|
"systemctl",
|
|
r#"#!/bin/sh
|
|
echo "$*" >> "$FAKE_STATE/calls"
|
|
running() { [ -f "$FAKE_STATE/pid" ] && kill -0 "$(/bin/cat "$FAKE_STATE/pid")" 2>/dev/null; }
|
|
halt() {
|
|
if running; then
|
|
kill "$(/bin/cat "$FAKE_STATE/pid")"
|
|
while running; do /bin/sleep 0.1; done
|
|
fi
|
|
/bin/rm -f "$FAKE_STATE/pid"
|
|
}
|
|
[ "$1" = "--user" ] || exit 64
|
|
shift
|
|
case "$1" in
|
|
show-environment) echo "HOME=$HOME" ;;
|
|
daemon-reload) ;;
|
|
enable) : > "$FAKE_STATE/enabled" ;;
|
|
disable) halt; /bin/rm -f "$FAKE_STATE/enabled" ;;
|
|
start) "$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 &
|
|
echo $! > "$FAKE_STATE/pid" ;;
|
|
stop) halt ;;
|
|
is-active) if running; then echo active; else echo inactive; exit 3; fi ;;
|
|
*) exit 64 ;;
|
|
esac
|
|
"#,
|
|
);
|
|
|
|
/// Where the service definition is written, under a given home.
|
|
fn definition_dir(home: &Path) -> PathBuf {
|
|
if cfg!(target_os = "macos") {
|
|
home.join("Library/LaunchAgents")
|
|
} else {
|
|
home.join(".config/systemd/user")
|
|
}
|
|
}
|
|
|
|
/// Where the configuration lives, under a given home.
|
|
fn config_file(home: &Path) -> PathBuf {
|
|
if cfg!(target_os = "macos") {
|
|
home.join("Library/Application Support/midi-harbor/config.yaml")
|
|
} else {
|
|
home.join(".config/midi-harbor/config.yaml")
|
|
}
|
|
}
|
|
|
|
/// A scratch machine: a home, a runtime directory, and a `PATH` holding only the stand-in.
|
|
///
|
|
/// Dropping it stops any daemon the stand-in started, so a failed assertion leaves nothing
|
|
/// running.
|
|
struct Machine {
|
|
root: PathBuf,
|
|
}
|
|
|
|
impl Machine {
|
|
fn new() -> Self {
|
|
let root = std::env::temp_dir().join(format!("mh-service-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
for dir in ["home", "bin", "state", "run"] {
|
|
std::fs::create_dir_all(root.join(dir)).unwrap();
|
|
}
|
|
let tool = root.join("bin").join(TOOL.0);
|
|
std::fs::write(&tool, TOOL.1).unwrap();
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
Self { root }
|
|
}
|
|
|
|
fn home(&self) -> PathBuf {
|
|
self.root.join("home")
|
|
}
|
|
|
|
/// Runs the binary, returning its exit code and standard output.
|
|
fn run(&self, arguments: &[&str]) -> (i32, String) {
|
|
let output = Command::new(env!("CARGO_BIN_EXE_midi-harbor"))
|
|
.args(arguments)
|
|
.env_clear()
|
|
// Only the stand-in is reachable, so the real service manager cannot be touched.
|
|
.env("PATH", self.root.join("bin"))
|
|
.env("HOME", self.home())
|
|
.env("TMPDIR", self.root.join("run"))
|
|
.env("XDG_RUNTIME_DIR", self.root.join("run"))
|
|
.env("FAKE_STATE", self.root.join("state"))
|
|
.env("FAKE_DAEMON", env!("CARGO_BIN_EXE_midi-harbor"))
|
|
.output()
|
|
.expect("the binary runs");
|
|
(
|
|
output.status.code().expect("an exit code"),
|
|
String::from_utf8_lossy(&output.stdout).into_owned(),
|
|
)
|
|
}
|
|
|
|
fn status(&self) -> serde_json::Value {
|
|
let (code, out) = self.run(&["--json", "service", "status"]);
|
|
assert_eq!(code, 0, "service status must succeed: {out}");
|
|
serde_json::from_str(&out).expect("service status writes one JSON document")
|
|
}
|
|
}
|
|
|
|
impl Drop for Machine {
|
|
fn drop(&mut self) {
|
|
if let Ok(pid) = std::fs::read_to_string(self.root.join("state/pid")) {
|
|
let _ = Command::new("/bin/kill").arg(pid.trim()).status();
|
|
}
|
|
let _ = std::fs::remove_dir_all(&self.root);
|
|
}
|
|
}
|
|
|
|
/// Locks the service lifecycle a user drives: install registers the daemon stopped, a second
|
|
/// install updates the one registration, start waits until the daemon answers, stop ends it, and
|
|
/// uninstall removes the registration and leaves the configuration byte for byte as it was.
|
|
///
|
|
/// Losing a user's ports and routes to an uninstall, or leaving two registrations to fight over
|
|
/// one socket, are the failures this guards.
|
|
#[test]
|
|
fn the_service_installs_starts_stops_and_uninstalls_in_place() {
|
|
let machine = Machine::new();
|
|
let config = config_file(&machine.home());
|
|
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
|
|
std::fs::write(&config, "preferences:\n advertise_sessions: false\n").unwrap();
|
|
|
|
// Install registers it, stopped.
|
|
let (code, out) = machine.run(&["service", "install"]);
|
|
assert_eq!(code, 0, "install must succeed: {out}");
|
|
let status = machine.status();
|
|
assert_eq!(
|
|
status["installed"], true,
|
|
"install must register the service: {status}"
|
|
);
|
|
assert_eq!(
|
|
status["running"], false,
|
|
"install alone must not start it: {status}"
|
|
);
|
|
assert_eq!(
|
|
status["stale"], false,
|
|
"a fresh registration must not be stale: {status}"
|
|
);
|
|
assert_eq!(
|
|
status["registered_executable"].as_str().map(PathBuf::from),
|
|
Some(PathBuf::from(env!("CARGO_BIN_EXE_midi-harbor"))),
|
|
"the registration must run the executable that installed it"
|
|
);
|
|
|
|
// Installing again updates the one registration rather than adding a second.
|
|
let (code, out) = machine.run(&["service", "install"]);
|
|
assert_eq!(code, 0, "a second install must succeed: {out}");
|
|
let definitions = std::fs::read_dir(definition_dir(&machine.home()))
|
|
.unwrap()
|
|
.count();
|
|
assert_eq!(
|
|
definitions, 1,
|
|
"a second install must update the one definition, not add one"
|
|
);
|
|
|
|
// Start runs the daemon and waits for it to answer; stop ends it.
|
|
let (code, out) = machine.run(&["service", "start"]);
|
|
assert_eq!(code, 0, "start must wait until the daemon answers: {out}");
|
|
assert_eq!(
|
|
machine.status()["running"],
|
|
true,
|
|
"the daemon must be running after start"
|
|
);
|
|
let (code, out) = machine.run(&["service", "stop"]);
|
|
assert_eq!(code, 0, "stop must succeed: {out}");
|
|
assert_eq!(
|
|
machine.status()["running"],
|
|
false,
|
|
"the daemon must be stopped after stop"
|
|
);
|
|
|
|
// Uninstall removes the registration and leaves the configuration exactly as it was. The
|
|
// daemon rewrote it while it ran, so the comparison is with what was there just before.
|
|
let before = std::fs::read_to_string(&config).unwrap();
|
|
let (code, out) = machine.run(&["service", "uninstall"]);
|
|
assert_eq!(code, 0, "uninstall must succeed: {out}");
|
|
assert_eq!(
|
|
machine.status()["installed"],
|
|
false,
|
|
"uninstall must remove the registration"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_dir(definition_dir(&machine.home()))
|
|
.unwrap()
|
|
.count(),
|
|
0,
|
|
"uninstall must leave no definition behind"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&config).unwrap(),
|
|
before,
|
|
"uninstall must leave the user's configuration untouched"
|
|
);
|
|
}
|