From 57858a1e1c037122deac1064cfbb11d00155bfd1 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Fri, 12 Jun 2026 02:31:45 -0400 Subject: [PATCH] feat(agent): systemd service install/uninstall subcommands (alpha.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For Saturday's Ubuntu host + Linux VM: 'corrosion-host-agent install' writes a systemd unit (Type=simple — the agent already handles SIGTERM cleanly), daemon-reloads, and enables+starts the service; 'uninstall' reverses it. - new service.rs: pure unit_file_contents() generator (unit-tested) + Linux install/uninstall via systemctl; non-Linux returns a clear 'Linux only' error (Windows SCM is the follow-up). - ExecStart honors the resolved --config path (default or explicit). - Runs as root: the agent supervises game processes + their files, needs broad filesystem access. cargo check + service unit test green. Tag agent-v2.0.0-alpha.11 -> CI signs -> CDN /host-agent/alpha/. Co-Authored-By: Claude Opus 4.8 --- corrosion-host-agent/Cargo.lock | 2 +- corrosion-host-agent/Cargo.toml | 2 +- corrosion-host-agent/src/lib.rs | 1 + corrosion-host-agent/src/main.rs | 8 +- corrosion-host-agent/src/service.rs | 129 ++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 corrosion-host-agent/src/service.rs diff --git a/corrosion-host-agent/Cargo.lock b/corrosion-host-agent/Cargo.lock index 826f044..282adfa 100644 --- a/corrosion-host-agent/Cargo.lock +++ b/corrosion-host-agent/Cargo.lock @@ -287,7 +287,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "corrosion-host-agent" -version = "2.0.0-alpha.10" +version = "2.0.0-alpha.11" dependencies = [ "anyhow", "async-nats", diff --git a/corrosion-host-agent/Cargo.toml b/corrosion-host-agent/Cargo.toml index ee9a686..c0df051 100644 --- a/corrosion-host-agent/Cargo.toml +++ b/corrosion-host-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "corrosion-host-agent" -version = "2.0.0-alpha.10" +version = "2.0.0-alpha.11" edition = "2021" description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers" license = "UNLICENSED" diff --git a/corrosion-host-agent/src/lib.rs b/corrosion-host-agent/src/lib.rs index 803ad32..87bfbd6 100644 --- a/corrosion-host-agent/src/lib.rs +++ b/corrosion-host-agent/src/lib.rs @@ -11,6 +11,7 @@ pub mod instancecmd; pub mod prober; pub mod process; pub mod rcon; +pub mod service; pub mod steamcmd; pub mod subjects; pub mod supervisor; diff --git a/corrosion-host-agent/src/main.rs b/corrosion-host-agent/src/main.rs index 86ea1ef..ddb1ff5 100644 --- a/corrosion-host-agent/src/main.rs +++ b/corrosion-host-agent/src/main.rs @@ -6,7 +6,7 @@ use corrosion_host_agent::{ agent, bus, config, docker_compose, filemanager, hostcmd, instancecmd, prober, process, - subjects, supervisor, telemetry, version, + service, subjects, supervisor, telemetry, version, }; use anyhow::{Context, Result}; @@ -37,6 +37,10 @@ enum Command { Check, /// Print full version (semver, git hash, build timestamp) and exit. Version, + /// Install as a systemd service and start it (Linux; requires root). + Install, + /// Stop and remove the systemd service (Linux; requires root). + Uninstall, } fn main() -> Result<()> { @@ -58,6 +62,8 @@ fn main() -> Result<()> { ); Ok(()) } + Some(Command::Install) => service::install(&config_path), + Some(Command::Uninstall) => service::uninstall(), None => { let settings = config::load(&config_path)?; init_logging(&settings.log_level); diff --git a/corrosion-host-agent/src/service.rs b/corrosion-host-agent/src/service.rs new file mode 100644 index 0000000..4765a60 --- /dev/null +++ b/corrosion-host-agent/src/service.rs @@ -0,0 +1,129 @@ +//! systemd service installation for the host agent (Linux). +//! +//! `corrosion-host-agent install` writes a systemd unit pointing at the current +//! binary + config, reloads systemd, and enables + starts the service. +//! `uninstall` reverses it. Windows SCM support is a follow-up; on non-Linux +//! these return a clear "Linux only" error rather than silently doing nothing. +//! +//! The agent already handles SIGTERM (see main::wait_for_shutdown_signal), so a +//! plain `Type=simple` unit gives systemd clean start/stop semantics. + +use anyhow::{bail, Result}; +use std::path::Path; + +#[cfg(target_os = "linux")] +use anyhow::Context; + +pub const SERVICE_NAME: &str = "corrosion-host-agent"; + +#[cfg(target_os = "linux")] +const UNIT_PATH: &str = "/etc/systemd/system/corrosion-host-agent.service"; + +/// Render the systemd unit. Pure (no I/O) so it is unit-testable. +pub fn unit_file_contents(exec_path: &str, config_path: &str) -> String { + format!( + "[Unit]\n\ + Description=Corrosion Host Agent (multi-game ops runtime)\n\ + Documentation=https://corrosionmgmt.com\n\ + After=network-online.target\n\ + Wants=network-online.target\n\ + \n\ + [Service]\n\ + Type=simple\n\ + ExecStart={exec} --config {cfg}\n\ + Restart=on-failure\n\ + RestartSec=5\n\ + # The agent supervises game-server processes and their files, so it\n\ + # needs broad filesystem access and runs as root by default.\n\ + User=root\n\ + \n\ + [Install]\n\ + WantedBy=multi-user.target\n", + exec = exec_path, + cfg = config_path, + ) +} + +#[cfg(target_os = "linux")] +pub fn install(config_path: &Path) -> Result<()> { + let exec = std::env::current_exe().context("resolving current executable path")?; + let exec_str = exec.to_string_lossy(); + let cfg_str = config_path.to_string_lossy(); + + let unit = unit_file_contents(&exec_str, &cfg_str); + std::fs::write(UNIT_PATH, unit) + .with_context(|| format!("writing {UNIT_PATH} (are you root?)"))?; + println!("wrote {UNIT_PATH}"); + + run("systemctl", &["daemon-reload"])?; + run("systemctl", &["enable", "--now", SERVICE_NAME])?; + + println!( + "service '{SERVICE_NAME}' installed and started.\n \ + status: systemctl status {SERVICE_NAME}\n \ + logs: journalctl -u {SERVICE_NAME} -f" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +pub fn uninstall() -> Result<()> { + // Best-effort stop+disable; don't fail if it isn't currently active. + let _ = std::process::Command::new("systemctl") + .args(["disable", "--now", SERVICE_NAME]) + .status(); + + if Path::new(UNIT_PATH).exists() { + std::fs::remove_file(UNIT_PATH) + .with_context(|| format!("removing {UNIT_PATH} (are you root?)"))?; + println!("removed {UNIT_PATH}"); + } + run("systemctl", &["daemon-reload"])?; + println!("service '{SERVICE_NAME}' uninstalled."); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn run(cmd: &str, args: &[&str]) -> Result<()> { + let status = std::process::Command::new(cmd) + .args(args) + .status() + .with_context(|| format!("running {cmd} {}", args.join(" ")))?; + if !status.success() { + bail!("{cmd} {} failed with {status}", args.join(" ")); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn install(_config_path: &Path) -> Result<()> { + bail!( + "`install` is only supported on Linux (systemd). Windows SCM support is \ + coming; for now run the agent directly or via your platform's service manager." + ); +} + +#[cfg(not(target_os = "linux"))] +pub fn uninstall() -> Result<()> { + bail!("`uninstall` is only supported on Linux (systemd)."); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_contains_exec_config_and_install_target() { + let u = unit_file_contents( + "/usr/local/bin/corrosion-host-agent", + "/etc/corrosion/agent.toml", + ); + assert!(u.contains( + "ExecStart=/usr/local/bin/corrosion-host-agent --config /etc/corrosion/agent.toml" + )); + assert!(u.contains("Type=simple")); + assert!(u.contains("Restart=on-failure")); + assert!(u.contains("WantedBy=multi-user.target")); + assert!(u.contains("After=network-online.target")); + } +}