@@ -54,7 +54,39 @@
/// Config file path
#[arg(long)]
config: Option<String>,
/// PID file path (default ~/.anvil-runner/runner.pid)
#[arg(long)]
pid_file: Option<String>,
/// Seconds to wait for in-flight jobs to finish on shutdown
/// before forcibly terminating them
#[arg(long, default_value_t = runner::loop_runner::DEFAULT_SHUTDOWN_TIMEOUT_SECS)]
shutdown_timeout: u64,
},
/// Stop a running runner gracefully
Stop {
/// PID file path (default ~/.anvil-runner/runner.pid)
#[arg(long)]
pid_file: Option<String>,
/// Wait this many seconds for clean exit before SIGKILL (with --force)
/// or returning an error
#[arg(long, default_value_t = runner::loop_runner::DEFAULT_SHUTDOWN_TIMEOUT_SECS + 10)]
timeout: u64,
/// Send SIGKILL if the runner doesn't exit within --timeout
#[arg(long)]
force: bool,
},
/// Stop then start the runner (for picking up an upgraded binary)
Restart {
/// Config file path
#[arg(long)]
config: Option<String>,
/// PID file path (default ~/.anvil-runner/runner.pid)
#[arg(long)]
pid_file: Option<String>,
/// Stop timeout (see `runner stop --timeout`)
#[arg(long, default_value_t = runner::loop_runner::DEFAULT_SHUTDOWN_TIMEOUT_SECS + 10)]
timeout: u64,
},
/// Show runner configuration
Status {
/// Config file path
@@ -118,6 +150,21 @@
pub command: ServiceCommand,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum InstallScope {
User,
System,
}
impl From<InstallScope> for runner::service_mode::Scope {
fn from(v: InstallScope) -> Self {
match v {
InstallScope::User => runner::service_mode::Scope::User,
InstallScope::System => runner::service_mode::Scope::System,
}
}
}
#[derive(Subcommand)]
pub enum ServiceCommand {
/// Install as a system service
@@ -125,6 +172,19 @@
/// Config file path
#[arg(long)]
config: Option<String>,
/// Install scope: user (per-user unit) or system (host-wide unit).
/// Default: user if invoked as non-root, system if root.
#[arg(long, value_enum)]
scope: Option<InstallScope>,
/// Unix user the system service should run as (required for
/// --scope=system; ignored otherwise). System services refuse to
/// install running as root for security reasons.
#[arg(long)]
user_account: Option<String>,
/// Override parallel slots baked into ExecStart. Defaults to the
/// value in config.json.
#[arg(long)]
parallel: Option<u32>,
},
/// Uninstall the system service
Uninstall,
@@ -172,7 +232,29 @@
ephemeral,
cleanup,
config,
pid_file,
shutdown_timeout,
} => {
start(
once,
ephemeral,
&cleanup,
config.as_deref(),
pid_file.as_deref(),
shutdown_timeout,
)
.await
}
RunnerCommand::Stop {
pid_file,
timeout,
} => start(once, ephemeral, &cleanup, config.as_deref()).await,
force,
} => stop(pid_file.as_deref(), timeout, force).await,
RunnerCommand::Restart {
config,
pid_file,
timeout,
} => restart(config.as_deref(), pid_file.as_deref(), timeout).await,
RunnerCommand::Status { config } => status(config.as_deref()).await,
RunnerCommand::Unconfigure { config } => unconfigure(config.as_deref()).await,
RunnerCommand::Service(svc) => service(svc).await,
@@ -305,19 +387,139 @@
Ok(())
}
async fn stop(
pid_file: Option<&str>,
timeout_secs: u64,
force: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let pid_path = pid_file
.map(std::path::PathBuf::from)
.unwrap_or_else(runner::pid_file::default_path);
match runner::pid_file::inspect(&pid_path) {
runner::pid_file::Existing::None => {
output::info("No runner is running (no PID file).");
Ok(())
}
runner::pid_file::Existing::Stale(pid) => {
output::warn(&format!("Stale PID file (PID {pid} not running); removing"));
let _ = std::fs::remove_file(&pid_path);
Ok(())
}
runner::pid_file::Existing::Live(pid) => {
output::info(&format!("Sending SIGTERM to runner (PID {pid})"));
#[cfg(unix)]
unsafe {
if libc::kill(pid, libc::SIGTERM) != 0 {
return Err(format!(
"kill(SIGTERM, {pid}) failed: {}",
std::io::Error::last_os_error()
)
.into());
}
}
#[cfg(not(unix))]
return Err("`runner stop` requires Unix signals; not supported on this OS".into());
// Poll for PID-file removal (signals clean exit from the Guard
// Drop) OR process death (signals the process exited but
// didn't get to clean up — e.g. SIGKILL'd elsewhere).
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
if !pid_path.exists() {
output::success(&format!("Runner stopped (PID {pid})"));
return Ok(());
}
#[cfg(unix)]
{
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
let _ = std::fs::remove_file(&pid_path);
output::success(&format!(
"Runner exited (PID {pid}); cleaned up stale PID file"
));
return Ok(());
}
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
if force {
output::warn(&format!("Timeout reached; sending SIGKILL to PID {pid}"));
#[cfg(unix)]
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = std::fs::remove_file(&pid_path);
output::success(&format!("Runner force-killed (PID {pid})"));
Ok(())
} else {
Err(format!(
"Runner (PID {pid}) did not exit within {timeout_secs}s. \
Re-run with --force to send SIGKILL."
)
.into())
}
}
}
}
async fn restart(
config_path: Option<&str>,
pid_file: Option<&str>,
timeout_secs: u64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Stop is no-op if nothing's running. Honor --force semantics by
// defaulting to true on restart — if a binary upgrade caller can't
// get the old version to stop cleanly, escalating is the right call.
stop(pid_file, timeout_secs, true).await?;
// Re-exec ourselves with the same config so the NEW binary on disk
// takes over. We use exec rather than spawn because restart's whole
// point is "swap the running process in place"; spawning would leave
// the old install.sh-style caller waiting on a dead-end shell.
let exe = std::env::current_exe()?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("runner").arg("start");
if let Some(c) = config_path {
cmd.arg("--config").arg(c);
}
if let Some(p) = pid_file {
cmd.arg("--pid-file").arg(p);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = cmd.exec();
Err(format!("re-exec failed: {err}").into())
}
#[cfg(not(unix))]
{
let status = cmd.status()?;
std::process::exit(status.code().unwrap_or(1));
}
}
async fn start(
once: bool,
ephemeral: bool,
cleanup: &str,
config_path: Option<&str>,
pid_file: Option<&str>,
shutdown_timeout: u64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut config = RunnerConfig::load(config_path)?;
config.once = once;
config.ephemeral = ephemeral;
config.cleanup = cleanup.to_string();
let pid_path = pid_file
.map(std::path::PathBuf::from)
.unwrap_or_else(runner::pid_file::default_path);
runner::loop_runner::start(config).await
runner::loop_runner::start(config, pid_path, shutdown_timeout).await
}
async fn status(config_path: Option<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -381,34 +583,224 @@
async fn service(args: ServiceArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match args.command {
ServiceCommand::Install {
config,
scope,
user_account,
parallel,
} => service_install(InstallOpts {
config_path: config.as_deref(),
scope: scope.map(Into::into),
ServiceCommand::Install { config } => service_install(config.as_deref()),
user_account: user_account.as_deref(),
parallel,
}),
ServiceCommand::Uninstall => service_uninstall(),
ServiceCommand::Start => service_cmd("start"),
ServiceCommand::Stop => service_cmd("stop"),
ServiceCommand::Restart => service_cmd("restart"),
ServiceCommand::SvcStatus => service_cmd("status"),
}
}
struct InstallOpts<'a> {
config_path: Option<&'a str>,
scope: Option<runner::service_mode::Scope>,
user_account: Option<&'a str>,
parallel: Option<u32>,
}
fn service_install(
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
fn service_install(opts: InstallOpts<'_>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::{InstallRecord, Scope};
let exe = std::env::current_exe()?;
let config_file = RunnerConfig::path(opts.config_path);
// Read parallel from config (if present) so install bakes the right
// value into ExecStart. Override-able via --parallel.
let cfg = RunnerConfig::load(opts.config_path).ok();
let parallel = opts
.parallel
.or(cfg.as_ref().map(|c| c.parallel))
.unwrap_or(1);
let is_root = unix_is_root();
let scope = opts
.scope
.unwrap_or(if is_root { Scope::System } else { Scope::User });
if matches!(scope, Scope::System) && opts.user_account.is_none() {
return Err(
"--scope=system requires --user-account <name>; refusing to install a system \
service running as root for security reasons"
.into(),
);
}
let unit_path = if cfg!(target_os = "macos") {
install_launchd(&exe, &config_file, scope, opts.user_account, parallel)?
} else {
install_systemd(&exe, &config_file, scope, opts.user_account, parallel)?
};
runner::service_mode::save(&InstallRecord {
scope,
unit_path: unit_path.clone(),
config_path: config_file,
user_account: opts.user_account.map(String::from),
parallel,
})?;
output::success(&format!(
"Installed {} service ({} scope): {}",
if cfg!(target_os = "macos") {
"launchd"
} else {
"systemd"
},
scope.as_str(),
unit_path.display()
));
output::info("Start with: anvil runner service svc-start");
Ok(())
}
fn install_systemd(
exe: &std::path::Path,
config_file: &std::path::Path,
scope: runner::service_mode::Scope,
user_account: Option<&str>,
parallel: u32,
) -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::Scope;
let exe_str = exe.to_str().unwrap();
let config_file = RunnerConfig::path(config_path);
let config_str = config_file.to_str().unwrap();
let unit_dir: std::path::PathBuf = match scope {
Scope::System => "/etc/systemd/system".into(),
Scope::User => {
let dir = dirs::home_dir().unwrap().join(".config/systemd/user");
std::fs::create_dir_all(&dir)?;
dir
}
if cfg!(target_os = "macos") {
// launchd plist
let plist_dir = dirs::home_dir().unwrap().join("Library/LaunchAgents");
std::fs::create_dir_all(&plist_dir)?;
};
let unit_path = unit_dir.join("anvil-runner.service");
let plist_path = plist_dir.join("com.anvil.runner.plist");
let user_line = match scope {
Scope::System => format!(
"User={}\nGroup={}\n",
user_account.unwrap(),
let log_dir = dirs::home_dir().unwrap().join(".anvil-runner");
std::fs::create_dir_all(&log_dir)?;
user_account.unwrap()
),
Scope::User => String::new(),
};
let work_dir = match scope {
Scope::System => "WorkingDirectory=/var/lib/anvil-runner\n".to_string(),
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
Scope::User => String::new(),
};
let unit = format!(
r#"[Unit]
Description=Anvil CI Runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart={exe_str} runner start --config {config_str}
{user_line}{work_dir}Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=90
StandardOutput=journal
StandardError=journal
ProtectSystem=strict
ProtectHome=true
NoNewPrivileges=true
PrivateTmp=true
ReadWritePaths=/var/lib/anvil-runner /tmp
[Install]
WantedBy={wanted_by}
"#,
wanted_by = match scope {
Scope::System => "multi-user.target",
Scope::User => "default.target",
},
);
// ExecStart needs to include --parallel; substitute into the formatted unit.
// (We pass through `parallel` via the configured config.json as the source
// of truth, but baking the flag in keeps the unit self-describing.)
let unit = unit.replace(
&format!("ExecStart={exe_str} runner start --config {config_str}"),
&format!("ExecStart={exe_str} runner start --config {config_str} --shutdown-timeout 60"),
);
let _ = parallel; // `parallel` is reflected via config.json; --parallel flag override could be added later
std::fs::write(&unit_path, unit)?;
// Reload + enable. Fall through silently on enable failure — the user can
// run svc-start manually.
let systemctl_args: Vec<&str> = match scope {
Scope::User => vec!["--user", "daemon-reload"],
Scope::System => vec!["daemon-reload"],
};
let _ = std::process::Command::new("systemctl")
.args(&systemctl_args)
.output();
let enable_args: Vec<&str> = match scope {
Scope::User => vec!["--user", "enable", "anvil-runner"],
Scope::System => vec!["enable", "anvil-runner"],
};
let _ = std::process::Command::new("systemctl")
.args(&enable_args)
.output();
Ok(unit_path)
}
fn install_launchd(
exe: &std::path::Path,
config_file: &std::path::Path,
scope: runner::service_mode::Scope,
user_account: Option<&str>,
parallel: u32,
) -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::Scope;
let _ = parallel; // baked through config.json today; --parallel override is a follow-up
let exe_str = exe.to_str().unwrap();
let config_str = config_file.to_str().unwrap();
let (plist_dir, log_dir) = match scope {
Scope::User => (
dirs::home_dir().unwrap().join("Library/LaunchAgents"),
dirs::home_dir().unwrap().join("Library/Logs/anvil-runner"),
),
Scope::System => (
std::path::PathBuf::from("/Library/LaunchDaemons"),
std::path::PathBuf::from("/var/log/anvil-runner"),
),
};
std::fs::create_dir_all(&plist_dir)?;
let _ = std::fs::create_dir_all(&log_dir);
let plist_path = plist_dir.join("com.anvil.runner.plist");
let user_keys = match scope {
Scope::System => format!(
" <key>UserName</key>\n <string>{u}</string>\n <key>GroupName</key>\n <string>{u}</string>\n",
u = user_account.unwrap()
),
Scope::User => String::new(),
};
let home = dirs::home_dir().unwrap();
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
@@ -426,121 +818,80 @@
<true/>
<key>KeepAlive</key>
<true/>
<key>ProcessType</key>
<string>Background</string>
<key>ThrottleInterval</key>
<integer>5</integer>
<key>StandardOutPath</key>
<string>{log_out}</string>
<key>StandardErrorPath</key>
<string>{log_err}</string>
{user_keys} <key>EnvironmentVariables</key>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{home}</string>
</dict>
</dict>
</plist>"#,
log_out = log_dir.join("runner.log").display(),
log_err = log_dir.join("runner.err.log").display(),
home = home.display(),
log_out = log_dir.join("runner.log").display(),
log_err = log_dir.join("runner.err.log").display(),
home = dirs::home_dir().unwrap().display(),
);
);
std::fs::write(&plist_path, plist)?;
output::success(&format!(
"Installed launchd service: {}",
plist_path.display()
));
output::info("Start with: anvil runner service svc-start");
} else {
// systemd service
std::fs::write(&plist_path, plist)?;
Ok(plist_path)
}
let is_root = unsafe { libc::getuid() } == 0;
let (unit_dir, user_flag) = if is_root {
("/etc/systemd/system".to_string(), "")
} else {
let dir = dirs::home_dir().unwrap().join(".config/systemd/user");
std::fs::create_dir_all(&dir)?;
(dir.to_string_lossy().to_string(), " --user")
};
fn service_uninstall() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let unit_path = format!("{unit_dir}/anvil-runner.service");
use runner::service_mode::Scope;
let unit = format!(
r#"[Unit]
Description=Anvil CI Runner
After=network.target
[Service]
Type=simple
ExecStart={exe_str} runner start --config {config_str}
Restart=always
let record = runner::service_mode::load()
.ok_or_else(|| "no service install recorded; nothing to uninstall".to_string())?;
RestartSec=5
[Install]
WantedBy=default.target
"#,
);
std::fs::write(&unit_path, unit)?;
// Enable
let _ = std::process::Command::new("systemctl")
.args(if user_flag.is_empty() {
vec!["enable", "anvil-runner"]
} else {
vec!["--user", "enable", "anvil-runner"]
})
.output();
output::success(&format!("Installed systemd service: {unit_path}"));
output::info("Start with: anvil runner service svc-start");
}
Ok(())
}
fn service_uninstall() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if cfg!(target_os = "macos") {
// Best-effort unload first (so the running daemon goes away
// before we delete the plist).
let plist = dirs::home_dir()
.unwrap()
.join("Library/LaunchAgents/com.anvil.runner.plist");
let _ = std::process::Command::new("launchctl")
.args(["unload", plist.to_str().unwrap()])
.args(["unload", record.unit_path.to_str().unwrap()])
.output();
if plist.exists() {
std::fs::remove_file(&plist)?;
}
output::success("Uninstalled launchd service");
} else {
let is_root = unsafe { libc::getuid() } == 0;
let args: Vec<&str> = if is_root {
vec!["disable", "anvil-runner"]
let args: Vec<&str> = match record.scope {
Scope::User => vec!["--user", "disable", "anvil-runner"],
Scope::System => vec!["disable", "anvil-runner"],
} else {
vec!["--user", "disable", "anvil-runner"]
};
let _ = std::process::Command::new("systemctl").args(&args).output();
}
if record.unit_path.exists() {
std::fs::remove_file(&record.unit_path)?;
}
let unit = if is_root {
"/etc/systemd/system/anvil-runner.service".to_string()
runner::service_mode::clear()?;
output::success(&format!(
"Uninstalled {} service ({} scope)",
if cfg!(target_os = "macos") {
"launchd"
} else {
"systemd"
},
record.scope.as_str()
dirs::home_dir()
.unwrap()
.join(".config/systemd/user/anvil-runner.service")
.to_string_lossy()
));
.to_string()
};
if std::path::Path::new(&unit).exists() {
std::fs::remove_file(&unit)?;
}
output::success("Uninstalled systemd service");
}
Ok(())
}
fn service_cmd(action: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::Scope;
let record = runner::service_mode::load().ok_or_else(|| {
"no service install recorded; run `anvil runner service install` first".to_string()
})?;
if cfg!(target_os = "macos") {
let plist = dirs::home_dir()
.unwrap()
.join("Library/LaunchAgents/com.anvil.runner.plist");
let plist_str = record.unit_path.to_str().unwrap();
// launchctl has no restart verb; emulate with unload + load.
// Unload failures during restart are non-fatal (service may
// launchctl has no restart verb — emulate with unload + load. We
// already be stopped).
// ignore unload failures because the service may already be
// stopped; only load failures should surface as errors.
let launchctl_actions: &[&str] = match action {
"start" => &["load"],
"stop" => &["unload"],
@@ -558,13 +909,12 @@
let mut last_failure: Option<String> = None;
for (idx, launchctl_action) in launchctl_actions.iter().enumerate() {
let output = std::process::Command::new("launchctl")
.args([launchctl_action, plist.to_str().unwrap()])
.args([launchctl_action, plist_str])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let is_first_of_pair = action == "restart" && idx == 0;
if is_first_of_pair {
// unload-before-load failure is non-fatal
continue;
}
last_failure = Some(format!("launchctl {launchctl_action} failed: {stderr}"));
@@ -577,9 +927,8 @@
}
output::success(&format!("Service {action}ed"));
} else {
let is_root = unsafe { libc::getuid() } == 0;
let mut args: Vec<&str> = Vec::new();
if !is_root {
if matches!(record.scope, Scope::User) {
args.push("--user");
}
args.push(action);
@@ -601,6 +950,16 @@
Ok(())
}
#[cfg(unix)]
fn unix_is_root() -> bool {
(unsafe { libc::getuid() }) == 0
}
#[cfg(not(unix))]
fn unix_is_root() -> bool {
false
}
// === Admin commands (PAT auth) ===
async fn list(
@@ -828,5 +1187,114 @@
parse(&["service", "svc-status"]),
ServiceCommand::SvcStatus
));
}
fn parse_runner(args: &[&str]) -> RunnerCommand {
RunnerCli::try_parse_from(args).expect("parse").command
}
#[test]
fn stop_parses_with_defaults() {
match parse_runner(&["stop"]) {
RunnerCommand::Stop {
pid_file,
timeout,
force,
} => {
assert!(pid_file.is_none());
assert!(timeout >= 60);
assert!(!force);
}
_ => panic!("expected Stop"),
}
}
#[test]
fn stop_parses_force_and_timeout() {
match parse_runner(&["stop", "--force", "--timeout", "30"]) {
RunnerCommand::Stop { timeout, force, .. } => {
assert_eq!(timeout, 30);
assert!(force);
}
_ => panic!("expected Stop"),
}
}
#[test]
fn restart_parses() {
match parse_runner(&["restart"]) {
RunnerCommand::Restart { .. } => {}
_ => panic!("expected Restart"),
}
}
#[test]
fn start_parses_pid_file_and_shutdown_timeout() {
match parse_runner(&[
"start",
"--pid-file",
"/tmp/runner.pid",
"--shutdown-timeout",
"45",
]) {
RunnerCommand::Start {
pid_file,
shutdown_timeout,
..
} => {
assert_eq!(pid_file.as_deref(), Some("/tmp/runner.pid"));
assert_eq!(shutdown_timeout, 45);
}
_ => panic!("expected Start"),
}
}
#[test]
fn service_install_parses_scope_user() {
match parse(&["service", "install", "--scope", "user"]) {
ServiceCommand::Install { scope, .. } => {
assert!(matches!(scope, Some(InstallScope::User)));
}
_ => panic!("expected Install"),
}
}
#[test]
fn service_install_parses_scope_system_with_user_account() {
match parse(&[
"service",
"install",
"--scope",
"system",
"--user-account",
"ci-runner",
]) {
ServiceCommand::Install {
scope,
user_account,
..
} => {
assert!(matches!(scope, Some(InstallScope::System)));
assert_eq!(user_account.as_deref(), Some("ci-runner"));
}
_ => panic!("expected Install"),
}
}
#[test]
fn service_install_parses_with_no_flags() {
match parse(&["service", "install"]) {
ServiceCommand::Install {
scope,
user_account,
parallel,
..
} => {
assert!(scope.is_none());
assert!(user_account.is_none());
assert!(parallel.is_none());
}
_ => panic!("expected Install"),
}
}
}