veltrix 0.6.0

Unix helpers, service integrations, and Unicode metadata
Documentation
use crate::error::{Result, VeltrixError};
use crate::os::process::cmd::{spec::CmdSpec, std_cmd};

use super::{
    spec::{SystemdBackendUsed, SystemdCliSpec, SystemdEmptyResponse, SystemdResponse},
    types::{
        SystemdDependency, SystemdJournal, SystemdResourceLimit, SystemdUnitFile, SystemdUnitStatus,
    },
};

/// systemd CLI client backed by systemctl and journalctl.
#[derive(Debug, Clone)]
pub struct SystemdCliClient {
    spec: SystemdCliSpec,
}

impl SystemdCliClient {
    pub fn new(spec: SystemdCliSpec) -> Self {
        Self { spec }
    }

    pub fn spec(&self) -> &SystemdCliSpec {
        &self.spec
    }

    pub fn start_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["start", unit])
    }

    pub fn stop_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["stop", unit])
    }

    pub fn restart_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["restart", unit])
    }

    pub fn reload_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["reload", unit])
    }

    pub fn enable_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["enable", unit])
    }

    pub fn enable_now_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["enable", "--now", unit])
    }

    pub fn disable_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["disable", unit])
    }

    pub fn mask_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["mask", unit])
    }

    pub fn unmask_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["unmask", unit])
    }

    pub fn daemon_reload(&self) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["daemon-reload"])
    }

    pub fn status(&self, unit: &str) -> Result<SystemdResponse<SystemdUnitStatus>> {
        let output = self.systemctl_output(["show", unit, "--no-page"])?;
        let data = parse_status(unit, &output);

        Ok(SystemdResponse::new(data, self.backend_used()))
    }

    pub fn properties(&self, unit: &str) -> Result<SystemdResponse<String>> {
        let output = self.systemctl_output(["show", unit, "--no-page"])?;

        Ok(SystemdResponse::new(output, self.backend_used()))
    }

    pub fn dependencies(&self, unit: &str) -> Result<SystemdResponse<Vec<SystemdDependency>>> {
        let output = self.systemctl_output(["list-dependencies", unit, "--plain", "--no-pager"])?;
        let data = output
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .map(|unit| SystemdDependency {
                unit: unit.to_string(),
            })
            .collect();

        Ok(SystemdResponse::new(data, self.backend_used()))
    }

    pub fn journal(
        &self,
        unit: &str,
        lines: Option<u32>,
    ) -> Result<SystemdResponse<SystemdJournal>> {
        let mut args = vec!["-u".to_string(), unit.to_string(), "--no-pager".to_string()];

        if let Some(lines) = lines {
            args.push("-n".to_string());
            args.push(lines.to_string());
        }

        let output = self.journalctl_output(args)?;
        Ok(SystemdResponse::new(
            SystemdJournal { output },
            self.backend_used(),
        ))
    }

    pub fn tail_journal(&self, unit: &str, lines: u32) -> Result<SystemdResponse<SystemdJournal>> {
        self.journal(unit, Some(lines))
    }

    pub fn journal_since(
        &self,
        unit: &str,
        since: &str,
        until: Option<&str>,
    ) -> Result<SystemdResponse<SystemdJournal>> {
        let mut args = vec![
            "-u".to_string(),
            unit.to_string(),
            "--since".to_string(),
            since.to_string(),
            "--no-pager".to_string(),
        ];
        if let Some(until) = until {
            args.push("--until".to_string());
            args.push(until.to_string());
        }

        let output = self.journalctl_output(args)?;
        Ok(SystemdResponse::new(
            SystemdJournal { output },
            self.backend_used(),
        ))
    }

    pub fn journal_boot(&self, unit: &str) -> Result<SystemdResponse<SystemdJournal>> {
        let output = self.journalctl_output(["-u", unit, "-b", "--no-pager"])?;
        Ok(SystemdResponse::new(
            SystemdJournal { output },
            self.backend_used(),
        ))
    }

    pub fn journal_priority(
        &self,
        unit: &str,
        priority: &str,
    ) -> Result<SystemdResponse<SystemdJournal>> {
        let output = self.journalctl_output(["-u", unit, "-p", priority, "--no-pager"])?;
        Ok(SystemdResponse::new(
            SystemdJournal { output },
            self.backend_used(),
        ))
    }

    pub fn edit_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["edit", unit])
    }

    pub fn edit_unit_drop_in(&self, unit: &str, drop_in: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["edit", unit, "--drop-in", drop_in])
    }

    pub fn edit_unit_full(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["edit", "--full", unit])
    }

    pub fn revert_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.systemctl_empty(["revert", unit])
    }

    pub fn list_unit_files(
        &self,
        pattern: Option<&str>,
    ) -> Result<SystemdResponse<Vec<SystemdUnitFile>>> {
        let mut args = vec!["list-unit-files".to_string(), "--no-pager".to_string()];
        if let Some(pattern) = pattern {
            args.push(pattern.to_string());
        }
        let output = self.systemctl_output(args)?;
        Ok(SystemdResponse::new(
            parse_unit_files(&output),
            self.backend_used(),
        ))
    }

    pub fn list_timers(&self) -> Result<SystemdResponse<String>> {
        let output = self.systemctl_output(["list-timers", "--all", "--no-pager"])?;
        Ok(SystemdResponse::new(output, self.backend_used()))
    }

    pub fn enable_timer(&self, timer: &str) -> Result<SystemdEmptyResponse> {
        self.enable_unit(timer)
    }

    pub fn disable_timer(&self, timer: &str) -> Result<SystemdEmptyResponse> {
        self.disable_unit(timer)
    }

    pub fn cat_unit(&self, unit: &str) -> Result<SystemdResponse<String>> {
        let output = self.systemctl_output(["cat", unit, "--no-pager"])?;
        Ok(SystemdResponse::new(output, self.backend_used()))
    }

    pub fn template_instance(template: &str, instance: &str) -> String {
        let template = template.trim_end_matches(".service").trim_end_matches('@');
        format!("{template}@{instance}.service")
    }

    pub fn set_resource_limits<I>(&self, unit: &str, limits: I) -> Result<SystemdEmptyResponse>
    where
        I: IntoIterator<Item = SystemdResourceLimit>,
    {
        let mut args = vec!["set-property".to_string(), unit.to_string()];
        args.extend(limits.into_iter().map(|limit| limit.assignment()));
        self.systemctl_empty(args)
    }

    pub fn watchdog_status(&self, unit: &str) -> Result<SystemdResponse<String>> {
        let output = self.systemctl_output([
            "show",
            unit,
            "-p",
            "WatchdogUSec",
            "-p",
            "WatchdogTimestamp",
        ])?;
        Ok(SystemdResponse::new(output, self.backend_used()))
    }

    pub fn set_watchdog_usec(
        &self,
        unit: &str,
        watchdog_usec: &str,
    ) -> Result<SystemdEmptyResponse> {
        self.set_resource_limits(
            unit,
            [SystemdResourceLimit::new("WatchdogUSec", watchdog_usec)],
        )
    }

    pub fn deploy_unit(&self, unit: &str) -> Result<SystemdEmptyResponse> {
        self.daemon_reload()?;
        self.enable_unit(unit)?;
        self.restart_unit(unit)
    }

    fn systemctl_empty<I, S>(&self, args: I) -> Result<SystemdEmptyResponse>
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.systemctl_output(args)?;
        Ok(SystemdEmptyResponse::new(self.backend_used()))
    }

    fn systemctl_output<I, S>(&self, args: I) -> Result<String>
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        run_command(base_systemctl_cmd(&self.spec).args(args), "systemd")
    }

    fn journalctl_output<I, S>(&self, args: I) -> Result<String>
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        run_command(base_journalctl_cmd(&self.spec).args(args), "systemd")
    }

    fn backend_used(&self) -> SystemdBackendUsed {
        SystemdBackendUsed::Cli {
            user: self.spec.user,
            sudo: self.spec.sudo,
        }
    }
}

fn base_systemctl_cmd(spec: &SystemdCliSpec) -> CmdSpec {
    let mut cmd = CmdSpec::new(&spec.systemctl);
    if spec.sudo {
        cmd = cmd.sudo();
    }
    if spec.user {
        cmd = cmd.arg("--user");
    }
    cmd
}

fn base_journalctl_cmd(spec: &SystemdCliSpec) -> CmdSpec {
    let mut cmd = CmdSpec::new(&spec.journalctl);
    if spec.sudo {
        cmd = cmd.sudo();
    }
    if spec.user {
        cmd = cmd.arg("--user");
    }
    cmd
}

fn run_command(cmd: CmdSpec, service: &str) -> Result<String> {
    let result = std_cmd::run(cmd)?;
    if !result.status.success() {
        return Err(VeltrixError::service(
            service,
            String::from_utf8_lossy(&result.stderr).trim().to_string(),
        ));
    }

    String::from_utf8(result.stdout)
        .map_err(|err| VeltrixError::parsing(format!("invalid systemd stdout: {err}")))
}

fn parse_status(unit: &str, output: &str) -> SystemdUnitStatus {
    SystemdUnitStatus {
        unit: unit.to_string(),
        active_state: property(output, "ActiveState"),
        sub_state: property(output, "SubState"),
        load_state: property(output, "LoadState"),
        description: property(output, "Description"),
    }
}

fn property(output: &str, key: &str) -> Option<String> {
    output.lines().find_map(|line| {
        let (name, value) = line.split_once('=')?;
        (name == key).then(|| value.to_string())
    })
}

fn parse_unit_files(output: &str) -> Vec<SystemdUnitFile> {
    output
        .lines()
        .filter_map(|line| {
            let mut parts = line.split_whitespace();
            let name = parts.next()?;
            if name == "UNIT" || name.ends_with(".") {
                return None;
            }
            Some(SystemdUnitFile {
                name: name.to_string(),
                state: parts.next().map(ToOwned::to_owned),
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_systemd_show_output() {
        let status = parse_status(
            "demo.service",
            "Description=Demo\nLoadState=loaded\nActiveState=active\nSubState=running\n",
        );
        assert_eq!(status.active_state.as_deref(), Some("active"));
        assert_eq!(status.description.as_deref(), Some("Demo"));
    }

    #[test]
    fn builds_template_instance_names() {
        assert_eq!(
            SystemdCliClient::template_instance("worker@.service", "blue"),
            "worker@blue.service"
        );
        assert_eq!(
            SystemdCliClient::template_instance("worker", "blue"),
            "worker@blue.service"
        );
    }

    #[test]
    fn resource_limit_formats_assignment() {
        assert_eq!(
            SystemdResourceLimit::new("MemoryMax", "512M").assignment(),
            "MemoryMax=512M"
        );
    }
}