ref:main
//! Records which service-manager mode the runner was installed under so
//! `svc-start/stop/restart/status` can target the correct scope without
//! the prior brittle "is the current uid 0?" heuristic.
//!
//! Persisted as one file per instance at
//! `~/.anvil-runner/services/<name>.json` so multiple distinct runners
//! can coexist on one host. The pre-multi-instance code wrote a single
//! `~/.anvil-runner/service.json`; on first read we migrate it into
//! `services/default.json` and leave the legacy file in place as a
//! rollback path.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// Default instance name when `--service-name` is omitted. Chosen so
/// every code path can rely on a concrete, non-empty string instead of
/// branching on `Option<String>`.
pub const DEFAULT_INSTANCE: &str = "default";
/// Validate `<name>` against `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` — same
/// charset that's safe in a systemd unit filename, a launchd Label, and
/// a plain filename across platforms.
pub fn validate_instance_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("service name must not be empty".into());
}
let first = name.as_bytes()[0];
if !first.is_ascii_alphanumeric() {
return Err("service name must start with a letter or digit".into());
}
for b in name.bytes() {
let ok = b.is_ascii_alphanumeric() || b == b'-' || b == b'_';
if !ok {
return Err(format!(
"service name '{name}' contains invalid character {:?}; \
allowed: letters, digits, '-', '_'",
b as char
));
}
}
Ok(())
}
/// On Linux: `user` writes to `~/.config/systemd/user/` and uses
/// `systemctl --user`; `system` writes to `/etc/systemd/system/` and uses
/// `systemctl` without `--user`.
///
/// On macOS: `user` writes a LaunchAgent under `~/Library/LaunchAgents/`;
/// `system` writes a LaunchDaemon under `/Library/LaunchDaemons/` (and
/// requires `UserName=`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
User,
System,
}
impl Scope {
pub fn as_str(self) -> &'static str {
match self {
Scope::User => "user",
Scope::System => "system",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallRecord {
/// Logical instance name (e.g., "default", "gpu1"). Determines the
/// unit filename and the launchd Label.
#[serde(default = "default_instance_name")]
pub instance: String,
pub scope: Scope,
/// Path to the unit/plist file we wrote, so uninstall can find it
/// without re-deriving it (which gets brittle when scopes differ).
pub unit_path: PathBuf,
/// Path to the runner config the unit was wired to.
pub config_path: PathBuf,
/// Username the service runs as. Always set for system scope; for
/// user scope this is the installing user's name (informational).
pub user_account: Option<String>,
/// Parallel-slot count baked into the unit's ExecStart.
pub parallel: u32,
/// systemd unit name or launchd Label, kept verbatim so callers
/// don't have to re-derive it (e.g. for `systemctl start <unit>` or
/// `launchctl bootstrap <domain> <plist>` lookups).
#[serde(default)]
pub unit_or_label: String,
}
fn default_instance_name() -> String {
DEFAULT_INSTANCE.to_string()
}
fn root_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".anvil-runner")
}
fn services_dir() -> PathBuf {
root_dir().join("services")
}
pub fn record_path(instance: &str) -> PathBuf {
services_dir().join(format!("{instance}.json"))
}
/// Legacy single-instance state path. Pre-multi-instance installs wrote
/// here. Migrated on first read.
fn legacy_path() -> PathBuf {
root_dir().join("service.json")
}
/// One-shot migrator: if the legacy single-file state exists and the
/// new `services/default.json` does NOT, copy the legacy record forward.
/// Idempotent — never overwrites an existing per-instance file and
/// leaves the legacy file in place as a rollback path.
fn migrate_legacy_if_needed() {
let new_default = record_path(DEFAULT_INSTANCE);
if new_default.exists() {
return;
}
let legacy = legacy_path();
let Ok(contents) = fs::read_to_string(&legacy) else {
return;
};
let Ok(mut record) = serde_json::from_str::<InstallRecord>(&contents) else {
return;
};
if record.instance.is_empty() {
record.instance = DEFAULT_INSTANCE.to_string();
}
// Legacy records pre-date the unit_or_label field. Derive it from
// the unit_path's filename: systemd unit files end in `.service`
// (we keep that suffix); launchd plist files end in `.plist` (we
// strip that to leave the Label).
if record.unit_or_label.is_empty() {
if let Some(name) = record.unit_path.file_name().and_then(|s| s.to_str()) {
record.unit_or_label = if let Some(stem) = name.strip_suffix(".plist") {
stem.to_string()
} else {
name.to_string()
};
}
}
if let Some(parent) = new_default.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(s) = serde_json::to_string_pretty(&record) {
let _ = fs::write(&new_default, s);
}
}
pub fn save(record: &InstallRecord) -> std::io::Result<()> {
let p = record_path(&record.instance);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(record).map_err(std::io::Error::other)?;
fs::write(p, contents)
}
/// Load the install record for the named instance. Runs the legacy
/// migrator first so pre-multi-instance state is picked up transparently.
pub fn load(instance: &str) -> Option<InstallRecord> {
migrate_legacy_if_needed();
let contents = fs::read_to_string(record_path(instance)).ok()?;
serde_json::from_str(&contents).ok()
}
/// List all installed instance names by reading the services dir.
pub fn list_instances() -> Vec<String> {
migrate_legacy_if_needed();
let dir = services_dir();
let Ok(entries) = fs::read_dir(&dir) else {
return Vec::new();
};
let mut names = Vec::new();
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
names.push(stem.to_string());
}
}
names.sort();
names
}
pub fn clear(instance: &str) -> std::io::Result<()> {
let p = record_path(instance);
if p.exists() {
fs::remove_file(p)?;
}
// If this was the "default" instance and the legacy file still
// exists, remove that too so the next install starts truly clean.
if instance == DEFAULT_INSTANCE {
let legacy = legacy_path();
if legacy.exists() {
let _ = fs::remove_file(legacy);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_instance_name_accepts_typical() {
assert!(validate_instance_name("default").is_ok());
assert!(validate_instance_name("gpu1").is_ok());
assert!(validate_instance_name("ci-build_2").is_ok());
assert!(validate_instance_name("A").is_ok());
}
#[test]
fn validate_instance_name_rejects_invalid() {
assert!(validate_instance_name("").is_err());
assert!(validate_instance_name("-foo").is_err());
assert!(validate_instance_name("foo bar").is_err());
assert!(validate_instance_name("foo/bar").is_err());
assert!(validate_instance_name("foo.bar").is_err());
}
#[test]
fn scope_round_trips_through_json() {
let record = InstallRecord {
instance: "default".into(),
scope: Scope::System,
unit_path: "/etc/systemd/system/anvil-runner-default.service".into(),
config_path: "/home/ci/.anvil-runner/config.json".into(),
user_account: Some("ci-runner".into()),
parallel: 4,
unit_or_label: "anvil-runner-default.service".into(),
};
let json = serde_json::to_string(&record).unwrap();
let parsed: InstallRecord = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.scope, Scope::System);
assert_eq!(parsed.parallel, 4);
assert_eq!(parsed.user_account.as_deref(), Some("ci-runner"));
assert_eq!(parsed.instance, "default");
}
#[test]
fn scope_serializes_as_lowercase() {
let json = serde_json::to_string(&Scope::User).unwrap();
assert_eq!(json, "\"user\"");
}
#[test]
fn record_path_uses_services_subdir() {
let p = record_path("gpu1");
assert!(p.to_string_lossy().contains("/services/gpu1.json"));
}
#[test]
fn legacy_record_without_instance_field_loads_as_default() {
// Pre-multi-instance JSON had no `instance` key.
let legacy_json = r#"{
"scope": "user",
"unit_path": "/tmp/anvil-runner.service",
"config_path": "/tmp/config.json",
"user_account": null,
"parallel": 1
}"#;
let record: InstallRecord = serde_json::from_str(legacy_json).unwrap();
assert_eq!(record.instance, "default");
assert_eq!(record.parallel, 1);
}
}