use std::ffi::OsString;
use std::io::Write;
use std::path::PathBuf;
use runner_manager_domain::model::StartMode;
use runner_manager_domain::store::{Store, StoreError};
use runner_manager_platform::service::{
HostControls, InstallRequest, ServiceError, ServiceIdentity, ServiceOperations,
WINDOWS_SCM_HOST_ARGUMENT,
};
use super::{CliError, Context, Failure, ServiceCommand, ServiceInstallArgs, write_failed};
pub fn dispatch(
context: &Context,
command: &ServiceCommand,
out: &mut dyn Write,
) -> Result<(), CliError> {
match command {
ServiceCommand::Install(args) => install(context, args, out),
ServiceCommand::Uninstall => uninstall(context, out),
ServiceCommand::Status => status(context, out),
}
}
pub(super) const SERVICE_TAG_VARIABLE: &str = "RUNNER_MANAGER_SERVICE_NAME_TAG";
pub(crate) fn operations(context: &Context) -> ServiceOperations {
ServiceOperations::with_controls(
context.paths().clone(),
identity(),
std::sync::Arc::new(HostControls),
)
}
pub(super) fn identity() -> ServiceIdentity {
match std::env::var(SERVICE_TAG_VARIABLE) {
Ok(tag) if !tag.trim().is_empty() => ServiceIdentity::fixture(tag.trim()),
_ => ServiceIdentity::product(),
}
}
fn announce_fixture(
operations: &ServiceOperations,
verb: &str,
subject: &'static str,
out: &mut dyn Write,
) -> Result<(), CliError> {
if !operations.identity().is_fixture() {
return Ok(());
}
writeln!(
out,
"note: {SERVICE_TAG_VARIABLE} is set, so this {verb} the test registration \
`{}` and not the installed service.",
operations.identity().name()
)
.map_err(write_failed(subject))
}
fn daemon_arguments(context: &Context, mode: StartMode) -> Vec<OsString> {
let paths = context.paths();
let mut arguments: Vec<OsString> = [
OsString::from("daemon"),
OsString::from("run"),
OsString::from("--service-config-dir"),
paths.config_dir().as_os_str().to_owned(),
OsString::from("--service-state-dir"),
paths.state_dir().as_os_str().to_owned(),
OsString::from("--service-runtime-dir"),
paths.runtime_dir().as_os_str().to_owned(),
OsString::from("--service-logs-dir"),
paths.logs_dir().as_os_str().to_owned(),
]
.into_iter()
.collect();
if cfg!(windows) && mode == StartMode::Boot {
arguments.push(OsString::from(WINDOWS_SCM_HOST_ARGUMENT));
}
arguments
}
struct OwnedCopy {
path: PathBuf,
replaced: Option<PathBuf>,
}
impl OwnedCopy {
fn restore(self) {
let Some(replaced) = self.replaced else {
let _ = std::fs::remove_file(&self.path);
return;
};
let _ = std::fs::remove_file(&self.path);
let _ = std::fs::rename(&replaced, &self.path);
}
}
fn install_owned_copy(context: &Context, source: &std::path::Path) -> Result<OwnedCopy, CliError> {
fn failed(what: &'static str) -> impl Fn(std::io::Error) -> CliError {
move |source| CliError::new(Failure::LocalState, format!("cannot {what}: {source}"))
}
let bin_dir = context.paths().state_dir().join("bin");
std::fs::create_dir_all(&bin_dir).map_err(failed("create the service binary directory"))?;
let owned = bin_dir.join(
source
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("runner-manager")),
);
let mut replaced = None;
if owned.exists() {
let aside = owned.with_extension("old");
let _ = std::fs::remove_file(&aside);
std::fs::rename(&owned, &aside)
.map_err(failed("move the previous service binary aside"))?;
replaced = Some(aside);
}
std::fs::copy(source, &owned).map_err(failed("copy the binary the service will run"))?;
Ok(OwnedCopy {
path: owned,
replaced,
})
}
pub fn install(
context: &Context,
args: &ServiceInstallArgs,
out: &mut dyn Write,
) -> Result<(), CliError> {
let failed = write_failed("this service installation");
let mode: StartMode = args.start_at.into();
let store = context.store()?;
let mut host = super::host::local_host_or_create(context, &store)?;
let previous = host.service_start_mode;
let operations = operations(context);
announce_fixture(&operations, "installs", "this service installation", out)?;
if let Some((purpose, path)) = context
.paths()
.all()
.into_iter()
.find(|(_, path)| !path.is_absolute())
{
return Err(CliError::with_remedy(
Failure::InvalidArgument,
format!(
"the {purpose} application-data directory {} is relative; a service starts in a different working directory and would open a different database",
path.display()
),
"runner-manager --data-dir <ABSOLUTE-DIR> service install",
));
}
let source = std::env::current_exe().map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot resolve this executable's own path: {source}"),
)
})?;
let owned = install_owned_copy(context, &source)?;
let request = InstallRequest::new(mode)
.for_binary(&owned.path)
.copied_from(&source)
.with_arguments(daemon_arguments(context, mode));
let installed = match operations.install(&request) {
Ok(installed) => installed,
Err(source) => {
owned.restore();
return Err(service_failure(source));
}
};
if let Err(source) = persist_mode(&store, &mut host, mode)
&& durable_mode(&store).ok().flatten() != Some(mode)
{
let rollback = operations.uninstall();
owned.restore();
return Err(rollback_failure("install", source, rollback.err()));
}
writeln!(
out,
"{}",
if installed.replaced_existing {
"Service re-registered, replacing the registration that was there."
} else {
"Service installed."
}
)
.map_err(failed)?;
writeln!(
out,
" start mode {}",
installed.record.start_mode
)
.map_err(failed)?;
writeln!(
out,
" binary {}",
installed.record.binary.display()
)
.map_err(failed)?;
writeln!(
out,
" diagnostic log {}",
installed.record.log_file.display()
)
.map_err(failed)?;
writeln!(
out,
" application data captured from this command's account"
)
.map_err(failed)?;
if installed.runner_root.path().is_some() {
writeln!(out, " runner root {}", installed.runner_root).map_err(failed)?;
}
if cfg!(target_os = "linux") {
writeln!(
out,
" Linux sandbox strict: workflows inherit the service sandbox and cannot elevate or write outside the configured application-data directories"
)
.map_err(failed)?;
}
if previous != mode {
writeln!(out, " host setting {previous} -> {mode}").map_err(failed)?;
}
Ok(())
}
pub fn uninstall(context: &Context, out: &mut dyn Write) -> Result<(), CliError> {
let operations = operations(context);
announce_fixture(&operations, "removes", "this service removal", out)?;
let result = operations.uninstall().map_err(service_failure)?;
writeln!(out, "{result}").map_err(write_failed("this service removal"))
}
pub fn status(context: &Context, out: &mut dyn Write) -> Result<(), CliError> {
status_with(&operations(context), out)
}
fn status_with(operations: &ServiceOperations, out: &mut dyn Write) -> Result<(), CliError> {
announce_fixture(operations, "reports", "this service status", out)?;
let status = operations.status().map_err(service_failure)?;
writeln!(out, "{status}").map_err(write_failed("this service status"))?;
if status.last_github_contact().is_none() {
writeln!(
out,
" GitHub connectivity offline (no successful contact recorded)"
)
.map_err(write_failed("this service status"))?;
}
if status.is_healthy() {
Ok(())
} else {
Err(CliError::with_remedy(
Failure::LocalState,
"the service status above contains one or more errors",
"runner-manager service uninstall && runner-manager service install",
))
}
}
fn persist_mode(
store: &dyn Store,
host: &mut runner_manager_domain::model::Host,
mode: StartMode,
) -> Result<(), StoreError> {
host.service_start_mode = mode;
store.put_host(host)
}
fn durable_mode(store: &dyn Store) -> Result<Option<StartMode>, StoreError> {
Ok(store
.hosts()?
.into_iter()
.next()
.map(|host| host.service_start_mode))
}
fn rollback_failure(
operation: &'static str,
source: StoreError,
rollback: Option<ServiceError>,
) -> CliError {
match rollback {
None => local_state(source),
Some(rollback) => CliError::new(
Failure::LocalState,
format!(
"could not {operation} in the local database: {source}. The service rollback also failed: {rollback}. Run `runner-manager service status` before retrying."
),
),
}
}
fn local_state(source: StoreError) -> CliError {
CliError::with_remedy(
Failure::LocalState,
format!("cannot persist this host's service start mode: {source}"),
"runner-manager service status",
)
}
fn service_failure(source: ServiceError) -> CliError {
let class = match source {
ServiceError::LockHeld { .. } | ServiceError::AlreadyInstalled { .. } => Failure::Conflict,
ServiceError::NotInstalled { .. } => Failure::NotFound,
ServiceError::BinaryPath { .. } | ServiceError::BinaryMissing { .. } => {
Failure::InvalidArgument
}
ServiceError::NeedsElevation { .. } => Failure::UnsupportedHost,
_ => Failure::LocalState,
};
let remedy = match &source {
ServiceError::Record { operation, .. } if *operation == "read" => {
"runner-manager service install"
}
_ => "runner-manager service status",
};
CliError::with_remedy(class, source.to_string(), remedy)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser as _;
use std::sync::Arc;
use crate::cli::{Cli, Command, DaemonCommand};
use runner_manager_domain::path::LocalAbsolutePath;
use runner_manager_platform::service::{RecordingControls, ServiceIdentity, ServiceOperations};
#[test]
fn the_service_binary_is_a_copy_this_product_owns_and_reinstalling_replaces_it() {
let temporary = tempfile::tempdir().unwrap();
let context = Context::resolve(Some(temporary.path()), &mut Vec::new()).unwrap();
let source = temporary.path().join("package-manager-owned.bin");
std::fs::write(&source, b"first version").unwrap();
let owned = install_owned_copy(&context, &source)
.expect("the copy is made")
.path;
assert!(owned.starts_with(context.paths().state_dir()), "{owned:?}");
assert_ne!(owned, source, "the service must not run the source itself");
assert_eq!(std::fs::read(&owned).unwrap(), b"first version");
std::fs::write(&source, b"second version").unwrap();
let again = install_owned_copy(&context, &source)
.expect("the copy is replaced")
.path;
assert_eq!(
again, owned,
"the registered path must not move between installs"
);
assert_eq!(
std::fs::read(&again).unwrap(),
b"second version",
"a stale copy would silently keep running the old daemon"
);
assert_eq!(
std::fs::read(owned.with_extension("old")).unwrap(),
b"first version",
"the previous copy is kept aside, not destroyed"
);
}
#[test]
fn a_failed_install_puts_back_the_binary_the_service_was_running() {
let temporary = tempfile::tempdir().unwrap();
let context = Context::resolve(Some(temporary.path()), &mut Vec::new()).unwrap();
let source = temporary.path().join("package-manager-owned.bin");
std::fs::write(&source, b"the version that is running").unwrap();
let running = install_owned_copy(&context, &source)
.expect("the copy is made")
.path;
std::fs::write(&source, b"the version that failed to install").unwrap();
let attempt = install_owned_copy(&context, &source).expect("the copy is replaced");
assert_eq!(
std::fs::read(&running).unwrap(),
b"the version that failed to install",
"the discriminator: the swap really happened before it was undone"
);
attempt.restore();
assert_eq!(
std::fs::read(&running).unwrap(),
b"the version that is running",
"a refused install must not move a running service to a new binary"
);
}
#[test]
fn a_failed_first_install_leaves_no_copy_behind() {
let temporary = tempfile::tempdir().unwrap();
let context = Context::resolve(Some(temporary.path()), &mut Vec::new()).unwrap();
let source = temporary.path().join("package-manager-owned.bin");
std::fs::write(&source, b"a version nothing registered").unwrap();
let attempt = install_owned_copy(&context, &source).expect("the copy is made");
let path = attempt.path.clone();
assert!(path.exists());
attempt.restore();
assert!(
!path.exists(),
"a copy no registration names is a copy that should not be there: {}",
path.display()
);
}
#[test]
fn installed_daemon_arguments_reproduce_all_four_directories_without_data_dir() {
let temporary = tempfile::tempdir().unwrap();
let context = Context::resolve(Some(temporary.path()), &mut Vec::new()).unwrap();
let arguments = daemon_arguments(&context, StartMode::Boot);
let mut argv = vec![OsString::from("runner-manager")];
argv.extend(arguments);
let cli = Cli::try_parse_from(argv).expect("service arguments must parse unattended");
assert!(
cli.data_dir.is_none(),
"--data-dir would re-root the secret store"
);
let Command::Daemon(DaemonCommand::Run(args)) = cli.command else {
panic!("wrong command");
};
assert_eq!(args.service_paths().as_ref(), Some(context.paths()));
assert_eq!(
args.windows_service_host,
cfg!(windows),
"only a Windows boot registration enters SCM"
);
let mut login_argv = vec![OsString::from("runner-manager")];
login_argv.extend(daemon_arguments(&context, StartMode::Login));
let login = Cli::try_parse_from(login_argv).expect("login arguments parse unattended");
let Command::Daemon(DaemonCommand::Run(login)) = login.command else {
panic!("wrong login command");
};
assert!(
!login.windows_service_host,
"Task Scheduler is not the Service Control Manager"
);
}
#[test]
fn stale_binary_status_prints_the_diagnosis_and_returns_an_error() {
let temporary = tempfile::tempdir().unwrap();
let context = Context::resolve(Some(temporary.path()), &mut Vec::new()).unwrap();
let binary = temporary.path().join("movable-runner-manager.exe");
std::fs::copy(std::env::current_exe().unwrap(), &binary).unwrap();
let operations = ServiceOperations::with_controls(
context.paths().clone(),
ServiceIdentity::fixture("f3-stale-binary"),
Arc::new(RecordingControls::new()),
)
.with_runner_root(
LocalAbsolutePath::new(
temporary
.path()
.join("runner-root")
.to_str()
.expect("a unicode temporary path"),
)
.expect("a local absolute path"),
);
operations
.install(&InstallRequest::new(StartMode::Boot).for_binary(&binary))
.unwrap();
std::fs::remove_file(binary).unwrap();
let mut out = Vec::new();
let error = status_with(&operations, &mut out).expect_err("stale is not healthy");
let rendered = String::from_utf8(out).unwrap();
assert_eq!(error.class(), Failure::LocalState);
assert!(rendered.contains("ERROR"));
assert!(rendered.contains("nothing is at the recorded path"));
assert!(rendered.contains("NOT healthy"));
}
}