use anyhow::{Context, Result};
use std::process::Command;
use std::time::Duration;
use tracing::{debug, error, info, warn};
const SERVICE_STOP_TIMEOUT_ATTEMPTS: u32 = 10;
#[derive(Debug, Clone)]
pub struct ServiceManager {
service_name: String,
}
impl ServiceManager {
pub fn new(service_name: String) -> Self {
Self { service_name }
}
pub fn is_service_running(&self) -> bool {
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if is_test_env {
return true;
}
match Command::new("sudo")
.args(["systemctl", "is-active", &self.service_name])
.output()
{
Ok(output) => {
let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
debug!(
"Service status: {} - success: {}",
status,
output.status.success()
);
status == "active"
}
Err(e) => {
debug!("Systemctl failed: {}", e);
false
}
}
}
pub fn check_service_status_readonly(&self) -> String {
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if is_test_env {
return "running".to_string();
}
match Command::new("systemctl")
.args(["is-active", &self.service_name])
.output()
{
Ok(output) => {
let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
debug!("Service status (no-sudo): {}", status);
match status.as_str() {
"active" => "running".to_string(),
"inactive" => "stopped".to_string(),
"failed" => "failed".to_string(),
"activating" => "starting".to_string(),
"deactivating" => "stopping".to_string(),
_ => "unknown".to_string(),
}
}
Err(e) => {
debug!("Systemctl failed (no-sudo): {}", e);
"unknown".to_string()
}
}
}
pub fn stop_service(&self) -> Result<()> {
info!("Stopping service: {}", self.service_name);
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if is_test_env {
info!("Test environment detected, skipping service stop");
return Ok(());
}
info!("Temporarily disabling service to prevent restart during update");
let disable_output = self.run_systemctl(&["disable", &self.service_name])?;
if !disable_output.status.success() {
warn!("Failed to disable service, continuing anyway");
}
let output = self.run_systemctl(&["stop", &self.service_name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if stderr.contains("Unit") && stderr.contains("not loaded") {
warn!(
"Service {} was not loaded (already stopped?)",
self.service_name
);
return Ok(());
}
anyhow::bail!(
"Failed to stop service {}: STDERR: {} STDOUT: {}",
self.service_name,
stderr,
stdout
);
}
for attempt in 1..=SERVICE_STOP_TIMEOUT_ATTEMPTS {
let status_output = self.run_systemctl(&["is-active", &self.service_name])?;
let status_string = String::from_utf8_lossy(&status_output.stdout);
let status = status_string.trim();
if status == "inactive" || status == "failed" {
info!("Service stopped successfully after {} attempts", attempt);
return Ok(());
}
debug!("Waiting for service to stop (attempt {})", attempt);
std::thread::sleep(Duration::from_secs(1));
}
warn!("Service may not have stopped gracefully within timeout, proceeding anyway");
Ok(())
}
pub fn start_service(&self) -> Result<()> {
info!("Starting service: {}", self.service_name);
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if is_test_env {
info!("Test environment detected, skipping service start");
return Ok(());
}
info!("Re-enabling service to restore automatic restart");
let enable_output = self.run_systemctl(&["enable", &self.service_name])?;
if !enable_output.status.success() {
warn!("Failed to re-enable service, but continuing");
}
if let Err(e) = self.run_systemctl(&["daemon-reload"]) {
warn!("Failed to reload systemd daemon: {}", e);
}
let output = self.run_systemctl(&["start", &self.service_name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
error!("Failed to issue start command for service: {}", stderr);
if let Ok(status_output) = self.run_systemctl(&["status", &self.service_name]) {
let status_info = String::from_utf8_lossy(&status_output.stdout);
error!("Service status after failed start:\n{}", status_info);
}
anyhow::bail!(
"Failed to start service {}: STDERR: {} STDOUT: {}",
self.service_name,
stderr,
stdout
);
}
info!("Service start command issued, verifying startup...");
Ok(())
}
pub fn wait_for_service_active(&self, timeout_secs: Option<u64>) -> Result<()> {
let timeout = timeout_secs.unwrap_or(100); let check_interval = 5; let max_attempts = timeout / check_interval;
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if is_test_env {
info!("Test environment detected, skipping service wait");
return Ok(());
}
info!("Resetting any previous failed state before verification...");
if let Err(e) = Command::new("sudo")
.args(["systemctl", "reset-failed", &self.service_name])
.output()
{
warn!("Failed to reset failed state (this is usually fine): {}", e);
}
std::thread::sleep(std::time::Duration::from_secs(3));
info!(
"Waiting for service to become active (max {} seconds)...",
timeout
);
for attempt in 1..=max_attempts {
std::thread::sleep(std::time::Duration::from_secs(check_interval));
if self.is_service_running() {
info!(
"✅ Service is active (attempt {}/{})",
attempt, max_attempts
);
return Ok(());
}
info!(
"⏳ Service not active yet (attempt {}/{}) - checking status...",
attempt, max_attempts
);
if let Ok(status_output) = Command::new("sudo")
.args([
"systemctl",
"status",
&self.service_name,
"--no-pager",
"-l",
])
.output()
{
let status = String::from_utf8_lossy(&status_output.stdout);
if status.contains("failed") || status.contains("Failed") {
let is_recent_failure = if let Ok(log_output) = Command::new("journalctl")
.args([
"-u",
&self.service_name,
"-n",
"5",
"--no-pager",
"--since=5 minutes ago",
])
.output()
{
let recent_logs = String::from_utf8_lossy(&log_output.stdout);
recent_logs.contains("Starting")
|| recent_logs.contains("Started")
|| recent_logs.contains("Stopping")
} else {
false
};
if is_recent_failure {
error!(
"❌ Service has failed state detected on attempt {} (recent failure)",
attempt
);
error!("Service status:\n{}", status);
if let Ok(log_output) = Command::new("journalctl")
.args(["-u", &self.service_name, "-n", "15", "--no-pager"])
.output()
{
let logs = String::from_utf8_lossy(&log_output.stdout);
error!("Recent service logs:\n{}", logs);
}
anyhow::bail!("Service failed to start - recent failure detected");
} else {
warn!(
"⚠️ Service shows failed state from before update (attempt {}) - continuing to wait",
attempt
);
continue;
}
}
if let Some(active_line) = status
.lines()
.find(|line| line.trim().starts_with("Active:"))
{
info!("Status: {}", active_line.trim());
}
} else {
warn!("Could not check service status on attempt {}", attempt);
}
}
anyhow::bail!("Service failed to become active within {} seconds", timeout);
}
pub fn get_service_status(&self) -> Result<String> {
let output = self.run_systemctl(&["status", &self.service_name, "--no-pager", "-l"])?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn get_service_logs(&self, lines: u32) -> Result<String> {
let output = Command::new("journalctl")
.args([
"-u",
&self.service_name,
"-n",
&lines.to_string(),
"--no-pager",
])
.output()
.context("Failed to get service logs")?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn restart_service(&self) -> Result<()> {
info!("Restarting service: {}", self.service_name);
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if is_test_env {
info!("Test environment detected, skipping service restart");
return Ok(());
}
let output = self.run_systemctl(&["restart", &self.service_name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"Failed to restart service {}: {}",
self.service_name,
stderr
);
}
info!("Service restart command issued successfully");
Ok(())
}
pub fn check_service_exists(&self) -> Result<()> {
let output = self.run_systemctl(&["is-active", &self.service_name])?;
let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
debug!("Service {} status: {}", self.service_name, status);
if status == "unknown" {
anyhow::bail!("Service {} not found", self.service_name);
}
Ok(())
}
pub fn enable_service(&self) -> Result<()> {
info!("Enabling service: {}", self.service_name);
let output = self.run_systemctl(&["enable", &self.service_name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to enable service {}: {}", self.service_name, stderr);
}
info!("Service enabled successfully");
Ok(())
}
pub fn disable_service(&self) -> Result<()> {
info!("Disabling service: {}", self.service_name);
let output = self.run_systemctl(&["disable", &self.service_name])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"Failed to disable service {}: {}",
self.service_name,
stderr
);
}
info!("Service disabled successfully");
Ok(())
}
pub fn reload_daemon(&self) -> Result<()> {
debug!("Reloading systemd daemon");
let output = self.run_systemctl(&["daemon-reload"])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to reload systemd daemon: {}", stderr);
}
Ok(())
}
fn run_systemctl(&self, args: &[&str]) -> Result<std::process::Output> {
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if cfg!(target_os = "macos") || cfg!(target_os = "windows") || is_test_env {
debug!(
"Skipping systemctl command in development/test environment: systemctl {}",
args.join(" ")
);
#[cfg(unix)]
let status = std::os::unix::process::ExitStatusExt::from_raw(0);
#[cfg(not(unix))]
let status = std::process::ExitStatus::from(std::process::ExitCode::SUCCESS);
return Ok(std::process::Output {
status,
stdout: b"active\n".to_vec(),
stderr: Vec::new(),
});
}
#[cfg(unix)]
let is_root = unsafe { libc::geteuid() == 0 };
#[cfg(not(unix))]
let is_root = false;
let mut command = if is_root {
let mut cmd = Command::new("systemctl");
cmd.args(args);
cmd
} else {
let mut cmd = Command::new("sudo");
cmd.arg("systemctl");
cmd.args(args);
cmd
};
command
.output()
.context(format!("Failed to run systemctl {}", args.join(" ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_service_manager_creation() {
let manager = ServiceManager::new("test_service".to_string());
assert_eq!(manager.service_name, "test_service");
}
#[test]
fn test_is_service_running_test_env() {
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
let manager = ServiceManager::new("test_service".to_string());
assert!(manager.is_service_running());
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_stop_service_test_env() {
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
let manager = ServiceManager::new("test_service".to_string());
let result = manager.stop_service();
assert!(result.is_ok());
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_start_service_test_env() {
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
let manager = ServiceManager::new("test_service".to_string());
let result = manager.start_service();
assert!(result.is_ok());
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_wait_for_service_active_test_env() {
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
let manager = ServiceManager::new("test_service".to_string());
let result = manager.wait_for_service_active(Some(1));
assert!(result.is_ok());
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_restart_service_test_env() {
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
let manager = ServiceManager::new("test_service".to_string());
let result = manager.restart_service();
assert!(result.is_ok());
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_enable_disable_service_test_env() {
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
let manager = ServiceManager::new("test_service".to_string());
let enable_result = manager.enable_service();
assert!(enable_result.is_ok());
let disable_result = manager.disable_service();
assert!(disable_result.is_ok());
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_reload_daemon_test_env() {
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
let manager = ServiceManager::new("test_service".to_string());
let result = manager.reload_daemon();
assert!(result.is_ok());
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_service_manager_debug() {
let manager = ServiceManager::new("test_service".to_string());
let debug_str = format!("{:?}", manager);
assert!(debug_str.contains("test_service"));
}
#[test]
fn test_service_manager_clone() {
let manager = ServiceManager::new("test_service".to_string());
let cloned = manager.clone();
assert_eq!(manager.service_name, cloned.service_name);
}
}