cindy 0.1.1

Managing infrastructure at breakneck speed.
Documentation
use std::process::Command;

use crate as cindy;
use crate::Context;

/// Desired install/remove status of a package.
#[derive(Clone, Debug, PartialEq, Eq)]
#[crate::wire]
pub enum Presence {
    /// `apt-get remove`: package not installed; config files may stay
    /// behind in `/etc/`. Idempotent over the already-removed and
    /// never-installed cases.
    Absent,
    /// `apt-get purge`: package not installed *and* config files
    /// removed. Cleaner than [`Absent`] when you want a clean slate for
    /// future reinstalls.
    Purged,
    /// `apt-get install <name>`: ensure the package is installed at
    /// any version. If it's already installed, this is a no-op (apt
    /// will not auto-upgrade an installed package on a bare `install`).
    Present,
    /// `apt-get install <name>=<version>`: ensure the package is
    /// installed at exactly this version string. The version string
    /// must match what `dpkg-query` reports for the package (including
    /// any epoch like `1:2.3.4-1`).
    Version(String),
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[crate::wire]
pub struct State {
    /// Package name as apt knows it (`nginx`, `python3-pip`, …).
    pub name: String,
    /// Desired install state. `None` ⇒ leave install state alone.
    pub presence: Option<Presence>,
    /// Desired `apt-mark hold` state. `None` ⇒ leave hold state alone.
    ///
    /// `Some(false)` first issues `apt-mark unhold`, then runs the
    /// presence step (so a version bump can land on a held package).
    /// `Some(true)` issues `apt-mark hold` *after* the presence step,
    /// re-holding at the post-install version. When `presence`
    /// requires a change and the package happens to be held, the hold
    /// is *transparently* released and (if `hold` is `None` or
    /// `Some(true)`) re-applied — i.e. you never need to spell out a
    /// manual unhold-just-to-upgrade dance.
    pub hold: Option<bool>,
}

/// Default `{:#?}`-based diff is fine here — the payload is just a
/// handful of names and small enums, no binary blobs to special-case.
impl crate::Diff for State {}

/// Snapshot of what `dpkg-query` / `apt-mark` say about the package
/// right now.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum InstallStatus {
    /// `dpkg-query` Status-Abbrev second char is `i` — fully installed.
    Installed,
    /// `dpkg-query` Status-Abbrev second char is `c` — package removed
    /// but config files remain (`rc` is the classic abbrev).
    ConfigFiles,
    /// Anything else (`un`, package unknown, empty output, …).
    NotInstalled,
}

struct CurrentState {
    install_status: InstallStatus,
    /// Installed version, if (and only if) `install_status == Installed`.
    version: Option<String>,
    /// `apt-mark` currently holding this package.
    held: bool,
}

/// Spawn a `Command` and return its `Output`, surfacing failures with
/// the combined stdout+stderr in the error message.
fn run_check(cmd: &mut Command) -> crate::Result<()> {
    let out = cmd
        .output()
        .context(format!("Failed to spawn {:?}", cmd.get_program()))?;
    if !out.status.success() {
        let stdout = String::from_utf8_lossy(&out.stdout);
        let stderr = String::from_utf8_lossy(&out.stderr);
        crate::bail!(
            "{:?} failed with {:?}:\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}",
            cmd.get_program(),
            out.status,
        );
    }
    Ok(())
}

/// Prebuilt `apt-get` invocation with `DEBIAN_FRONTEND=noninteractive`
/// + `-y` so prompts don't stall the run.
fn apt_get() -> Command {
    let mut c = Command::new("apt-get");
    c.env("DEBIAN_FRONTEND", "noninteractive");
    c.arg("-y");
    c
}

fn read_current_state(pkg: &str) -> crate::Result<CurrentState> {
    // Status-Abbrev is two characters: `<desired><current>`. We only
    // care about the current state for "is it installed?". The format
    // string deliberately puts `|` between Status-Abbrev and Version
    // so we can split unambiguously even on packages whose version
    // contains spaces (in theory it shouldn't, but defence-in-depth).
    let dpkg = Command::new("dpkg-query")
        .args(["-W", "-f", "${db:Status-Abbrev}|${Version}", "--", pkg])
        .output()
        .context("Failed to spawn `dpkg-query`")?;

    let (install_status, version) = if dpkg.status.success() {
        let raw = String::from_utf8_lossy(&dpkg.stdout);
        let (abbrev, ver) = raw.split_once('|').unwrap_or((raw.as_ref(), ""));
        let current = abbrev.chars().nth(1).unwrap_or(' ');
        let status = match current {
            'i' => InstallStatus::Installed,
            'c' => InstallStatus::ConfigFiles,
            _ => InstallStatus::NotInstalled,
        };
        let version = matches!(status, InstallStatus::Installed).then(|| ver.trim().to_owned());
        (status, version)
    } else {
        // `dpkg-query` exits non-zero when the package is unknown to
        // dpkg. That's a perfectly valid "not installed" outcome — not
        // an error condition for us.
        (InstallStatus::NotInstalled, None)
    };

    let mark = Command::new("apt-mark")
        .arg("showhold")
        .output()
        .context("Failed to spawn `apt-mark showhold`")?;
    if !mark.status.success() {
        crate::bail!(
            "`apt-mark showhold` failed:\n{}",
            String::from_utf8_lossy(&mark.stderr)
        );
    }
    let held = String::from_utf8_lossy(&mark.stdout)
        .lines()
        .any(|line| line.trim() == pkg);

    Ok(CurrentState {
        install_status,
        version,
        held,
    })
}

/// Render `CurrentState` as a `Presence` for diff display.
fn current_to_presence(cur: &CurrentState) -> Presence {
    match cur.install_status {
        InstallStatus::Installed => match &cur.version {
            Some(v) => Presence::Version(v.clone()),
            None => Presence::Present,
        },
        InstallStatus::ConfigFiles => Presence::Absent,
        InstallStatus::NotInstalled => Presence::Purged,
    }
}

/// Does the live state already satisfy the desired `Presence`?
fn presence_satisfied(cur: &CurrentState, desired: &Presence) -> bool {
    match desired {
        Presence::Absent => matches!(
            cur.install_status,
            InstallStatus::ConfigFiles | InstallStatus::NotInstalled
        ),
        Presence::Purged => matches!(cur.install_status, InstallStatus::NotInstalled),
        Presence::Present => matches!(cur.install_status, InstallStatus::Installed),
        Presence::Version(v) => {
            cur.install_status == InstallStatus::Installed
                && cur.version.as_deref() == Some(v.as_str())
        }
    }
}

/// Manage a single apt package on a Debian/Ubuntu remote.
///
/// Reads the current install status via `dpkg-query` and hold state via
/// `apt-mark showhold`, then runs the minimum set of `apt-get` /
/// `apt-mark` commands needed to bring the system into the desired
/// state. `DEBIAN_FRONTEND=noninteractive` is set for every apt call so
/// debconf prompts don't stall the run.
///
/// The apt **package cache is not refreshed** by this module — if you
/// need a fresh `apt-get update` (because you're chasing a newly
/// published `Version(...)` or installing a package that wasn't in the
/// cache the last time someone ran `update`), do it from a separate
/// `#[cindy::remote]` of your own.
#[crate::remote]
pub fn apt(state: State) -> crate::Result<super::Return> {
    let cur = read_current_state(&state.name)?;

    // Build diff views. `old_view` is what we observed on disk;
    // `new_view` is what the system will look like after we're done,
    // with any unspecified (`None`) fields back-filled from `cur` so
    // the diff highlights only what's actually changing.
    let old_view = State {
        name: state.name.clone(),
        presence: Some(current_to_presence(&cur)),
        hold: Some(cur.held),
    };
    let new_view = State {
        name: state.name.clone(),
        presence: Some(
            state
                .presence
                .clone()
                .unwrap_or_else(|| current_to_presence(&cur)),
        ),
        hold: Some(state.hold.unwrap_or(cur.held)),
    };
    if old_view != new_view {
        let _ = <State as crate::Diff>::diff(&old_view, &new_view, &mut std::io::stderr().lock());
    }

    let mut changed = false;
    let mut effective_held = cur.held;

    let presence_needs_change = state
        .presence
        .as_ref()
        .is_some_and(|p| !presence_satisfied(&cur, p));

    // Final hold state we want when this function returns. If the
    // caller didn't pin `hold`, we preserve whatever the package
    // started at — even when step 1 below has to release a transient
    // hold to land a version bump.
    let final_held = state.hold.unwrap_or(effective_held);

    // Step 1: unhold if (a) the caller asked for it, or (b) a presence
    // change is needed on a held package. The latter is transparent —
    // step 3 re-holds afterwards if `final_held` ends up true.
    if effective_held && (state.hold == Some(false) || presence_needs_change) {
        run_check(Command::new("apt-mark").args(["unhold", "--", &state.name]))?;
        effective_held = false;
        changed = true;
    }

    // Step 2: apply the presence change.
    if let Some(presence) = state.presence.as_ref()
        && !presence_satisfied(&cur, presence)
    {
        match presence {
            Presence::Absent => {
                run_check(apt_get().args(["remove", "--", &state.name]))?;
            }
            Presence::Purged => {
                run_check(apt_get().args(["purge", "--", &state.name]))?;
            }
            Presence::Present => {
                run_check(apt_get().args(["install", "--", &state.name]))?;
            }
            Presence::Version(v) => {
                let spec = format!("{}={}", state.name, v);
                run_check(apt_get().args(["install", "--", &spec]))?;
            }
        }
        changed = true;
    }

    // Step 3: re-establish the final hold state.
    if effective_held != final_held {
        let subcmd = if final_held { "hold" } else { "unhold" };
        run_check(Command::new("apt-mark").args([subcmd, "--", &state.name]))?;
        changed = true;
    }

    Ok(if changed {
        super::Return::Changed
    } else {
        super::Return::Unchanged
    })
}