reeve-cli 0.3.0

Localhost web dev stack manager: web servers, per-vhost PHP versions, SSL, and DNS — RunCloud, scaled down.
//! systemd (Linux) service management. php-fpm masters and web servers run as
//! systemd **user** units under `~/.config/systemd/user/reeve-<service>.service`,
//! driven with `systemctl --user`. Mirrors the launchd backend's public API and
//! its tolerate-absence semantics exactly, so callers are platform-agnostic.

use super::{ServiceSpec, Status};
use anyhow::{bail, Context, Result};
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};

const UNIT_PREFIX: &str = "reeve";

/// The label for a managed service. On Linux the systemd unit name *is* the
/// label, e.g. `reeve-php-83`. The `.service` suffix is added where a full unit
/// name is required (see [`unit_name`]).
pub fn label(service: &str) -> String {
    format!("{UNIT_PREFIX}-{service}")
}

/// Full systemd unit name, e.g. `reeve-php-83.service`.
fn unit_name(service: &str) -> String {
    format!("{}.service", label(service))
}

/// `~/.config/systemd/user`.
fn unit_dir() -> Result<PathBuf> {
    let base = dirs::config_dir().context("Could not determine config directory")?;
    Ok(base.join("systemd/user"))
}

fn unit_path(service: &str) -> Result<PathBuf> {
    Ok(unit_dir()?.join(unit_name(service)))
}

/// Run a `systemctl --user <args>` command, capturing output (never inherited,
/// so nothing leaks into the TUI's alternate screen).
fn systemctl(args: &[&str]) -> Result<std::process::Output> {
    Command::new("systemctl")
        .arg("--user")
        .args(args)
        .stdin(Stdio::null())
        .output()
        .context("Failed to run systemctl --user")
}

fn daemon_reload() {
    let _ = systemctl(&["daemon-reload"]);
}

/// Escape a single ExecStart token for systemd. systemd word-splits ExecStart on
/// unquoted whitespace and expands `%` specifiers, so wrap each token in double
/// quotes (spaces safe) and escape `\`, `"`, and `%`.
fn systemd_quote(token: &str) -> String {
    let escaped = token
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('%', "%%");
    format!("\"{escaped}\"")
}

/// `StandardOutput=append:` / `StandardError=` take the rest of the line
/// literally, so no quoting — but a value must not contain a newline.
fn sanitize_path_value(p: &std::path::Path) -> String {
    p.display().to_string().replace('\n', " ")
}

fn render_unit(spec: &ServiceSpec) -> String {
    let mut exec = systemd_quote(&spec.program.display().to_string());
    for arg in &spec.args {
        exec.push(' ');
        exec.push_str(&systemd_quote(arg));
    }
    let log = sanitize_path_value(&spec.log);

    let restart = if spec.keep_alive {
        "Restart=always\nRestartSec=1\n"
    } else {
        "Restart=no\n"
    };
    let install = if spec.run_at_load {
        "\n[Install]\nWantedBy=default.target\n"
    } else {
        ""
    };

    format!(
        "# Generated by reeve — do not edit by hand.\n\
         [Unit]\n\
         Description=reeve managed service {service}\n\
         \n\
         [Service]\n\
         Type=exec\n\
         ExecStart={exec}\n\
         {restart}\
         StandardOutput=append:{log}\n\
         StandardError=append:{log}\n\
         Environment=HOME=%h\n\
         {install}",
        service = spec.service,
    )
}

/// Write the unit file and reload the manager so it's known. On Linux `install`
/// only *prepares* the unit (mirroring launchd, which writes the plist without
/// loading it); the subsequent `restart`/`load` starts it. When `run_at_load`
/// is set we also `enable` (without `--now`) so the unit comes back on boot.
pub fn install(spec: &ServiceSpec) -> Result<()> {
    let dir = unit_dir()?;
    fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
    if let Some(parent) = spec.log.parent() {
        fs::create_dir_all(parent).ok();
    }
    let path = unit_path(&spec.service)?;
    fs::write(&path, render_unit(spec))
        .with_context(|| format!("Failed to write unit {}", path.display()))?;
    daemon_reload();
    if spec.run_at_load {
        // Create the default.target.wants symlink (boot persistence) without
        // starting it — start happens on the caller's follow-up restart.
        let _ = systemctl(&["enable", &unit_name(&spec.service)]);
    }
    Ok(())
}

/// Load (start) a service. Mirrors launchd: bail if the unit was never
/// installed. Part of the preserved daemon API; `restart` is used in its place
/// everywhere in-crate, so it has no caller on Linux.
#[allow(dead_code)]
pub fn load(service: &str) -> Result<()> {
    let path = unit_path(service)?;
    if !path.exists() {
        bail!("No unit for service '{service}'. Install it first.");
    }
    let out = systemctl(&["start", &unit_name(service)])?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        if !stderr.trim().is_empty() {
            bail!("systemctl --user start failed: {}", stderr.trim());
        }
    }
    Ok(())
}

/// Unload (stop) a service. Tolerates a missing or already-inactive unit.
pub fn unload(service: &str) -> Result<()> {
    let path = unit_path(service)?;
    if !path.exists() {
        return Ok(());
    }
    let out = systemctl(&["stop", &unit_name(service)])?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        // "not loaded" / "not active" is the desired end state already.
        let benign = stderr.contains("not loaded")
            || stderr.contains("not active")
            || stderr.contains("not found");
        if !benign && !stderr.trim().is_empty() {
            bail!("systemctl --user stop failed: {}", stderr.trim());
        }
    }
    Ok(())
}

/// Restart. `systemctl restart` starts a stopped unit and re-reads the unit file
/// (already reloaded by `install`), so a rewritten `ExecStart` is picked up.
pub fn restart(service: &str) -> Result<()> {
    let path = unit_path(service)?;
    if !path.exists() {
        bail!("No unit for service '{service}'. Install it first.");
    }
    let out = systemctl(&["restart", &unit_name(service)])?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        if !stderr.trim().is_empty() {
            bail!("systemctl --user restart failed: {}", stderr.trim());
        }
    }
    Ok(())
}

/// Remove a service entirely: disable + stop, delete the unit file, reload.
/// Tolerates absence throughout.
pub fn uninstall(service: &str) -> Result<()> {
    let path = unit_path(service)?;
    if path.exists() {
        let _ = systemctl(&["disable", "--now", &unit_name(service)]);
        fs::remove_file(&path)
            .with_context(|| format!("Failed to remove unit {}", path.display()))?;
        daemon_reload();
    }
    Ok(())
}

/// Query a service's status from `systemctl --user show -p ActiveState`.
pub fn status(service: &str) -> Status {
    let out = systemctl(&["show", "-p", "ActiveState", "--value", &unit_name(service)]);
    match out {
        Ok(o) if o.status.success() => {
            match String::from_utf8_lossy(&o.stdout).trim() {
                "active" | "activating" | "reloading" => Status::Running,
                "failed" => Status::Error,
                // "inactive", "deactivating", or empty (unknown unit) → stopped.
                _ => Status::Stopped,
            }
        }
        _ => Status::Stopped,
    }
}

/// The PID systemd reports for a service (`MainPID`), if running. 0 means none.
pub fn pid(service: &str) -> Option<u32> {
    let out = systemctl(&["show", "-p", "MainPID", "--value", &unit_name(service)]).ok()?;
    if !out.status.success() {
        return None;
    }
    match String::from_utf8_lossy(&out.stdout).trim().parse::<u32>() {
        Ok(0) | Err(_) => None,
        Ok(p) => Some(p),
    }
}

// ---- Linux host helpers (used by `init` and `doctor`) --------------------

/// The current user's login name, for `loginctl` operations.
fn current_user() -> String {
    std::env::var("USER")
        .ok()
        .filter(|u| !u.is_empty())
        .unwrap_or_else(|| {
            Command::new("id")
                .arg("-un")
                .output()
                .ok()
                .and_then(|o| String::from_utf8(o.stdout).ok())
                .map(|s| s.trim().to_string())
                .unwrap_or_default()
        })
}

/// Enable login lingering so user units keep running (and start at boot) even
/// when the user is not logged in. Needs no root for one's own user on most
/// setups; returns an error (with the command to run) if it fails.
pub fn enable_linger() -> Result<()> {
    let user = current_user();
    let out = Command::new("loginctl")
        .args(["enable-linger", &user])
        .output()
        .context("Failed to run loginctl enable-linger")?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        bail!(
            "loginctl enable-linger failed: {}. Run `sudo loginctl enable-linger {user}` manually.",
            stderr.trim()
        );
    }
    Ok(())
}

/// Is login lingering already enabled for the current user?
pub fn linger_enabled() -> bool {
    let user = current_user();
    Command::new("loginctl")
        .args(["show-user", &user, "-p", "Linger", "--value"])
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
        .unwrap_or(false)
}

/// Does `systemctl --user` actually respond? It fails inside bare containers
/// with no user session bus; we report that rather than crashing.
pub fn user_bus_ok() -> bool {
    match systemctl(&["is-system-running"]) {
        Ok(o) => {
            let combined = format!(
                "{}{}",
                String::from_utf8_lossy(&o.stdout),
                String::from_utf8_lossy(&o.stderr)
            );
            // Any real answer (running/degraded/starting/…) means the bus is up.
            // "Failed to connect to bus" / "Failed to connect to user scope" mean
            // there's no user session bus.
            !combined.contains("Failed to connect")
        }
        Err(_) => false,
    }
}