5.3 KiB · text History d69022d
//! systemd control abstraction for the reconciler.
//!
//! The production implementation shells out to `systemctl` rather than talking
//! to the D-Bus API directly. That is deliberate: podman quadlets are expanded
//! into transient units by a systemd *generator* at `daemon-reload` time, and
//! `systemctl` is the interface that reliably sees generated units, resolves
//! aliases, and reports `is-enabled` states like `generated`/`static`. Shelling
//! out also keeps the dependency surface tiny.

use std::io;
use std::process::Command;

/// Side-effecting systemd operations, injectable for testing.
pub trait SystemdController {
    /// `systemctl daemon-reload` — re-run generators, pick up new/changed units.
    fn daemon_reload(&mut self) -> io::Result<()>;

    /// Whether the unit is currently active (running).
    fn is_active(&self, unit: &str) -> io::Result<bool>;

    /// The raw `systemctl is-active` state string (`active`, `inactive`,
    /// `failed`, `activating`, …) — richer than [`Self::is_active`], used for
    /// traffic-light health reporting. Default derives from `is_active` so
    /// test fakes need not implement it.
    fn active_state(&self, unit: &str) -> io::Result<String> {
        Ok(if self.is_active(unit)? {
            "active".to_string()
        } else {
            "inactive".to_string()
        })
    }

    /// Whether the unit is enabled (starts on boot). Generated/static units,
    /// which cannot be toggled, are reported as enabled so the reconciler
    /// leaves them alone.
    fn is_enabled(&self, unit: &str) -> io::Result<bool>;

    fn start(&mut self, unit: &str) -> io::Result<()>;
    fn stop(&mut self, unit: &str) -> io::Result<()>;
    fn restart(&mut self, unit: &str) -> io::Result<()>;
    fn reload(&mut self, unit: &str) -> io::Result<()>;
    fn enable(&mut self, unit: &str) -> io::Result<()>;
    fn disable(&mut self, unit: &str) -> io::Result<()>;
}

/// systemd management scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Scope {
    /// System manager (`systemctl ...`). Requires root for mutations.
    #[default]
    System,
    /// Per-user manager (`systemctl --user ...`).
    User,
}

/// Production [`SystemdController`] that invokes the `systemctl` binary.
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemctlController {
    scope: Scope,
}

impl SystemctlController {
    pub fn new(scope: Scope) -> Self {
        Self { scope }
    }

    fn command(&self) -> Command {
        let mut cmd = Command::new("systemctl");
        if self.scope == Scope::User {
            cmd.arg("--user");
        }
        cmd
    }

    /// Run a mutating verb and map a non-zero exit into an error carrying stderr.
    fn run(&self, verb: &str, unit: Option<&str>) -> io::Result<()> {
        let mut cmd = self.command();
        cmd.arg(verb);
        if let Some(u) = unit {
            cmd.arg(u);
        }
        let out = cmd.output()?;
        if out.status.success() {
            Ok(())
        } else {
            let stderr = String::from_utf8_lossy(&out.stderr);
            Err(io::Error::other(format!(
                "systemctl {verb}{} exited with {}: {}",
                unit.map(|u| format!(" {u}")).unwrap_or_default(),
                out.status,
                stderr.trim()
            )))
        }
    }

    /// Run a query verb, returning trimmed stdout regardless of exit status
    /// (query verbs use exit code to signal state, not failure).
    fn query(&self, verb: &str, unit: &str) -> io::Result<String> {
        let out = self.command().arg(verb).arg(unit).output()?;
        Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
    }
}

impl SystemdController for SystemctlController {
    fn daemon_reload(&mut self) -> io::Result<()> {
        self.run("daemon-reload", None)
    }

    fn is_active(&self, unit: &str) -> io::Result<bool> {
        // `is-active` prints "active" (exit 0) or "inactive"/"failed"/... (exit
        // non-zero). We treat only "active" as active; "activating" and
        // "failed" are not the steady state we want, so drift triggers action.
        Ok(self.query("is-active", unit)? == "active")
    }

    fn active_state(&self, unit: &str) -> io::Result<String> {
        self.query("is-active", unit)
    }

    fn is_enabled(&self, unit: &str) -> io::Result<bool> {
        let state = self.query("is-enabled", unit)?;
        Ok(matches!(
            state.as_str(),
            "enabled"
                | "enabled-runtime"
                | "alias"
                // Not toggleable via enable/disable — report as enabled so the
                // reconciler doesn't fight them.
                | "static"
                | "indirect"
                | "generated"
                | "transient"
        ))
    }

    fn start(&mut self, unit: &str) -> io::Result<()> {
        self.run("start", Some(unit))
    }
    fn stop(&mut self, unit: &str) -> io::Result<()> {
        self.run("stop", Some(unit))
    }
    fn restart(&mut self, unit: &str) -> io::Result<()> {
        self.run("restart", Some(unit))
    }
    fn reload(&mut self, unit: &str) -> io::Result<()> {
        self.run("reload", Some(unit))
    }
    fn enable(&mut self, unit: &str) -> io::Result<()> {
        self.run("enable", Some(unit))
    }
    fn disable(&mut self, unit: &str) -> io::Result<()> {
        self.run("disable", Some(unit))
    }
}