21.3 KiB · text History d69022d
//! The reconciler: drive the local system toward a [`Manifest`].
//!
//! This module is pure in the sense that all side effects go through two
//! traits — [`FileSystem`] and [`SystemdController`] — plus a [`ContentSource`]
//! for fetching file bodies. Production wires these to [`fs::RealFs`],
//! [`systemd::SystemctlController`], and a blob-backed source; tests wire them
//! to in-memory fakes. That makes idempotency (the property that a second
//! apply of the same manifest changes nothing) directly assertable.
//!
//! Reconcile order mirrors Ansible + podman-quadlet best practice:
//! 1. Install/remove files, tracking which systemd-affecting files changed.
//! 2. `daemon-reload` iff a systemd-affecting file changed (so quadlet
//!    generators re-run and new units become visible).
//! 3. Reconcile services (enable/disable, then run-state), restarting units
//!    only when a config file changed this pass — handler semantics.

use std::collections::HashSet;
use std::io;
use std::path::Path;

use devendra_proto::{
    ApplyResult, ChangeAction, ChangeRecord, DirResource, FileResource, FileState, Manifest,
    PackageResource, ServiceResource, ServiceState, content_hash,
};

pub mod fs;
pub mod pkg;
pub mod systemd;

pub use fs::{FileMeta, FileSystem, RealFs};
pub use pkg::{PackageManager, SystemPackageManager};
pub use systemd::{SystemctlController, SystemdController};

/// Supplies the intended bytes for a file resource. Implementations verify
/// nothing themselves; the reconciler checks the returned bytes against the
/// resource's `content_blake3` before writing.
pub trait ContentSource {
    /// Return the intended content for `resource`. Only called when the
    /// reconciler has determined the on-disk file is missing or stale, so this
    /// may be comparatively expensive (e.g. a blob read).
    fn fetch(&self, resource: &FileResource) -> io::Result<Vec<u8>>;
}

/// The outcome of a reconcile pass, before it is stamped with a timestamp and
/// promoted to a [`devendra_proto::Status`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
    pub generation: u64,
    pub result: ApplyResult,
    pub changes: Vec<ChangeRecord>,
}

impl Report {
    /// Whether any resource actually changed the system.
    pub fn changed_anything(&self) -> bool {
        self.changes.iter().any(|c| c.action.is_change())
    }
}

/// Everything the reconciler needs, grouped so a call site reads clearly.
pub struct Reconciler<'a> {
    pub fs: &'a mut dyn FileSystem,
    pub systemd: &'a mut dyn SystemdController,
    pub packages: &'a mut dyn PackageManager,
    pub content: &'a dyn ContentSource,
}

impl<'a> Reconciler<'a> {
    /// Apply `manifest` with no prior state — equivalent to
    /// [`Self::apply_with_prior`] with `None`. No pruning happens.
    pub fn apply(&mut self, manifest: &Manifest) -> Report {
        self.apply_with_prior(manifest, None)
    }

    /// Apply `manifest`, returning a per-resource report. Never panics on a
    /// single resource failure: a failing resource is recorded as
    /// [`ChangeAction::Failed`] and reconciliation continues, so one bad unit
    /// can't strand the rest of the host.
    ///
    /// When `prior` (the last manifest this agent applied) is given, resources
    /// that were present then but are gone now are *pruned*: dropped services
    /// are stopped + disabled and dropped files are removed, so removing a
    /// fragment undoes what it installed. Directories and packages are
    /// deliberately never auto-pruned (removing a data dir or a possibly-shared
    /// package is too dangerous) — use an explicit `state = "absent"` for those.
    pub fn apply_with_prior(&mut self, manifest: &Manifest, prior: Option<&Manifest>) -> Report {
        let mut changes = Vec::new();
        let mut systemd_files_changed = false;

        // Prune phase: undo resources that dropped out of the desired state.
        // Stopping dropped services happens first, while their units still
        // exist (before we remove the quadlet files and daemon-reload).
        if let Some(prior) = prior {
            let kept_services: HashSet<&str> =
                manifest.services.iter().map(|s| s.name.as_str()).collect();
            for svc in &prior.services {
                if !kept_services.contains(svc.name.as_str()) {
                    self.prune_service(svc, &mut changes);
                }
            }
            let kept_files: HashSet<&str> =
                manifest.files.iter().map(|f| f.path.as_str()).collect();
            for file in &prior.files {
                if file.state == FileState::Present && !kept_files.contains(file.path.as_str()) {
                    let record = self.prune_file(file);
                    if record.action.is_change()
                        && record.action != ChangeAction::Failed
                        && file.affects_systemd()
                    {
                        systemd_files_changed = true;
                    }
                    changes.push(record);
                }
            }
        }

        // Phase 0a: packages. Installed first, since files and services may
        // depend on them (e.g. a package that ships a systemd unit).
        for pkg in &manifest.packages {
            changes.push(self.reconcile_package(pkg));
        }

        // Phase 0b: directories. Ensured before files, since a file may live
        // inside a managed directory. Directories never affect systemd.
        for dir in &manifest.directories {
            changes.push(self.reconcile_dir(dir));
        }

        // Phase 1: files. Track whether any systemd-relevant file changed, to
        // decide on daemon-reload, and whether *any* file changed, to drive
        // handler-style restarts.
        let mut any_file_changed = false;
        for file in &manifest.files {
            let record = self.reconcile_file(file);
            if record.action.is_change() && record.action != ChangeAction::Failed {
                any_file_changed = true;
                if file.affects_systemd() {
                    systemd_files_changed = true;
                }
            }
            changes.push(record);
        }

        // Phase 2: daemon-reload only when a unit/generator file actually
        // changed. Reloading is cheap but not free, and doing it unconditionally
        // would make every pass report a change.
        if systemd_files_changed {
            match self.systemd.daemon_reload() {
                Ok(()) => changes.push(ok(
                    "systemd",
                    ChangeAction::Acted,
                    "daemon-reload (unit files changed)",
                )),
                Err(e) => changes.push(fail("systemd", format!("daemon-reload failed: {e}"))),
            }
        }

        // Phase 3: services. Enablement first, then run-state.
        for svc in &manifest.services {
            self.reconcile_service(svc, any_file_changed, &mut changes);
        }

        let result = summarize(&changes);
        Report {
            generation: manifest.generation,
            result,
            changes,
        }
    }

    /// Stop and disable a service that is no longer in the desired state.
    /// Best-effort per action so one failure doesn't block the other.
    fn prune_service(&mut self, svc: &ServiceResource, changes: &mut Vec<ChangeRecord>) {
        match self.systemd.is_active(&svc.name) {
            Ok(true) => match self.systemd.stop(&svc.name) {
                Ok(()) => changes.push(ok(
                    &svc.name,
                    ChangeAction::Acted,
                    "stopped (no longer managed)",
                )),
                Err(e) => changes.push(fail(&svc.name, format!("prune stop: {e}"))),
            },
            Ok(false) => {}
            Err(e) => changes.push(fail(&svc.name, format!("prune is-active: {e}"))),
        }
        match self.systemd.is_enabled(&svc.name) {
            Ok(true) => match self.systemd.disable(&svc.name) {
                Ok(()) => changes.push(ok(
                    &svc.name,
                    ChangeAction::Acted,
                    "disabled (no longer managed)",
                )),
                Err(e) => changes.push(fail(&svc.name, format!("prune disable: {e}"))),
            },
            Ok(false) => {}
            Err(e) => changes.push(fail(&svc.name, format!("prune is-enabled: {e}"))),
        }
    }

    /// Remove a file that is no longer in the desired state.
    fn prune_file(&mut self, file: &FileResource) -> ChangeRecord {
        let path = Path::new(&file.path);
        match self.fs.metadata(path) {
            Ok(None) => ok(&file.path, ChangeAction::Unchanged, "already absent"),
            Ok(Some(_)) => match self.fs.remove(path) {
                Ok(()) => ok(
                    &file.path,
                    ChangeAction::Removed,
                    "removed (no longer managed)",
                ),
                Err(e) => fail(&file.path, format!("prune remove: {e}")),
            },
            Err(e) => fail(&file.path, format!("prune stat: {e}")),
        }
    }

    /// Reconcile a single file resource. Returns exactly one record describing
    /// the net action (content write dominates; a mode/owner-only fixup reports
    /// `Updated`).
    fn reconcile_file(&mut self, file: &FileResource) -> ChangeRecord {
        let path = Path::new(&file.path);

        if file.state == FileState::Absent {
            return match self.fs.metadata(path) {
                Ok(None) => ok(&file.path, ChangeAction::Unchanged, "already absent"),
                Ok(Some(_)) => match self.fs.remove(path) {
                    Ok(()) => ok(&file.path, ChangeAction::Removed, ""),
                    Err(e) => fail(&file.path, format!("remove failed: {e}")),
                },
                Err(e) => fail(&file.path, format!("stat failed: {e}")),
            };
        }

        let expected_hash = match &file.content_blake3 {
            Some(h) => h.as_str(),
            None => {
                return fail(
                    &file.path,
                    "present file has no content_blake3 — controller bug".to_string(),
                );
            }
        };

        let current = match self.fs.read(path) {
            Ok(c) => c,
            Err(e) => return fail(&file.path, format!("read failed: {e}")),
        };

        let mut created = current.is_none();
        let mut changed = false;

        // Content: fetch + write only when missing or hash mismatch.
        let content_matches = current
            .as_deref()
            .map(|c| content_hash(c) == expected_hash)
            .unwrap_or(false);
        if !content_matches {
            let desired = match self.content.fetch(file) {
                Ok(b) => b,
                Err(e) => return fail(&file.path, format!("content fetch failed: {e}")),
            };
            let got = content_hash(&desired);
            if got != expected_hash {
                return fail(
                    &file.path,
                    format!("content hash mismatch: expected {expected_hash}, got {got}"),
                );
            }
            if let Err(e) = self.fs.write(path, &desired) {
                return fail(&file.path, format!("write failed: {e}"));
            }
            changed = true;
        }

        // Mode: apply when specified and different (or on create).
        if let Some(mode) = file.mode {
            let current_mode = match self.fs.metadata(path) {
                Ok(Some(m)) => Some(m.mode),
                Ok(None) => None,
                Err(e) => return fail(&file.path, format!("stat failed: {e}")),
            };
            if current_mode != Some(mode) {
                if let Err(e) = self.fs.set_mode(path, mode) {
                    return fail(&file.path, format!("chmod failed: {e}"));
                }
                changed = true;
            }
        }

        // Owner / group: resolve names to ids and chown when different.
        if file.owner.is_some() || file.group.is_some() {
            match self.reconcile_ownership(path, file.owner.as_deref(), file.group.as_deref()) {
                Ok(true) => changed = true,
                Ok(false) => {}
                Err(e) => return fail(&file.path, format!("chown failed: {e}")),
            }
        }

        let action = if created {
            ChangeAction::Created
        } else if changed {
            ChangeAction::Updated
        } else {
            ChangeAction::Unchanged
        };
        // `created` implies changed; keep the flag honest for callers reading
        // action alone.
        let _ = &mut created;
        ok(&file.path, action, "")
    }

    /// Reconcile a single package: install when present-but-missing, remove
    /// when absent-but-present. Idempotent — a package already in the desired
    /// state is left alone.
    fn reconcile_package(&mut self, pkg: &PackageResource) -> ChangeRecord {
        let installed = match self.packages.is_installed(&pkg.name) {
            Ok(v) => v,
            Err(e) => return fail(&pkg.name, format!("query failed: {e}")),
        };
        match (pkg.state, installed) {
            (FileState::Present, true) => {
                ok(&pkg.name, ChangeAction::Unchanged, "already installed")
            }
            (FileState::Present, false) => match self.packages.install(&pkg.name) {
                Ok(()) => ok(&pkg.name, ChangeAction::Created, "installed"),
                Err(e) => fail(&pkg.name, format!("install failed: {e}")),
            },
            (FileState::Absent, false) => ok(&pkg.name, ChangeAction::Unchanged, "already absent"),
            (FileState::Absent, true) => match self.packages.remove(&pkg.name) {
                Ok(()) => ok(&pkg.name, ChangeAction::Removed, "removed"),
                Err(e) => fail(&pkg.name, format!("remove failed: {e}")),
            },
        }
    }

    /// Reconcile a single directory resource: ensure it exists (creating
    /// parents) with the desired mode/owner, or remove it when absent.
    fn reconcile_dir(&mut self, dir: &DirResource) -> ChangeRecord {
        let path = Path::new(&dir.path);
        let meta = match self.fs.metadata(path) {
            Ok(m) => m,
            Err(e) => return fail(&dir.path, format!("stat failed: {e}")),
        };

        if dir.state == FileState::Absent {
            return match meta {
                None => ok(&dir.path, ChangeAction::Unchanged, "already absent"),
                Some(_) => match self.fs.remove_dir(path) {
                    Ok(()) => ok(&dir.path, ChangeAction::Removed, ""),
                    Err(e) => fail(&dir.path, format!("remove failed: {e}")),
                },
            };
        }

        // Present: create if missing; error if the path exists but isn't a dir.
        let created = meta.is_none();
        if let Some(m) = meta {
            if !m.is_dir {
                return fail(&dir.path, "path exists but is not a directory".to_string());
            }
        } else if let Err(e) = self.fs.create_dir(path) {
            return fail(&dir.path, format!("mkdir failed: {e}"));
        }

        let mut changed = created;

        // Mode: default 0o755 on create; otherwise only touch on drift.
        let want_mode = dir.mode.or(if created { Some(0o755) } else { None });
        if let Some(mode) = want_mode {
            let current_mode = match self.fs.metadata(path) {
                Ok(Some(m)) => Some(m.mode),
                Ok(None) => None,
                Err(e) => return fail(&dir.path, format!("stat failed: {e}")),
            };
            if current_mode != Some(mode) {
                if let Err(e) = self.fs.set_mode(path, mode) {
                    return fail(&dir.path, format!("chmod failed: {e}"));
                }
                changed = true;
            }
        }

        // Owner / group.
        if dir.owner.is_some() || dir.group.is_some() {
            match self.reconcile_ownership(path, dir.owner.as_deref(), dir.group.as_deref()) {
                Ok(true) => changed = true,
                Ok(false) => {}
                Err(e) => return fail(&dir.path, format!("chown failed: {e}")),
            }
        }

        let action = if created {
            ChangeAction::Created
        } else if changed {
            ChangeAction::Updated
        } else {
            ChangeAction::Unchanged
        };
        ok(&dir.path, action, "")
    }

    /// Resolve `owner`/`group` names to ids and chown `path` when they differ
    /// from the current owner. Returns Ok(true) if it actually changed ownership.
    fn reconcile_ownership(
        &mut self,
        path: &Path,
        owner: Option<&str>,
        group: Option<&str>,
    ) -> io::Result<bool> {
        let want_uid = match owner {
            Some(name) => Some(self.fs.resolve_user(name)?),
            None => None,
        };
        let want_gid = match group {
            Some(name) => Some(self.fs.resolve_group(name)?),
            None => None,
        };
        let meta = self.fs.metadata(path)?;
        let (cur_uid, cur_gid) = match meta {
            Some(m) => (Some(m.uid), Some(m.gid)),
            None => (None, None),
        };
        let uid_differs = want_uid.is_some() && want_uid != cur_uid;
        let gid_differs = want_gid.is_some() && want_gid != cur_gid;
        if uid_differs || gid_differs {
            self.fs.chown(
                path,
                if uid_differs { want_uid } else { None },
                if gid_differs { want_gid } else { None },
            )?;
            Ok(true)
        } else {
            Ok(false)
        }
    }

    fn reconcile_service(
        &mut self,
        svc: &ServiceResource,
        files_changed: bool,
        changes: &mut Vec<ChangeRecord>,
    ) {
        // Enablement.
        if let Some(want_enabled) = svc.enabled {
            match self.systemd.is_enabled(&svc.name) {
                Ok(cur) if cur == want_enabled => {}
                Ok(_) => {
                    let r = if want_enabled {
                        self.systemd.enable(&svc.name)
                    } else {
                        self.systemd.disable(&svc.name)
                    };
                    match r {
                        Ok(()) => changes.push(ok(
                            &svc.name,
                            ChangeAction::Acted,
                            if want_enabled { "enabled" } else { "disabled" },
                        )),
                        Err(e) => changes.push(fail(&svc.name, format!("enable/disable: {e}"))),
                    }
                }
                Err(e) => changes.push(fail(&svc.name, format!("is-enabled: {e}"))),
            }
        }

        // Run-state.
        let Some(state) = svc.state else { return };
        let record = match state {
            ServiceState::Started => self.ensure_active(svc, true),
            ServiceState::Stopped => self.ensure_active(svc, false),
            ServiceState::Restarted => {
                // Handler semantics: restart when a config file changed this
                // pass; otherwise just make sure it's running. This keeps
                // repeat applies idempotent instead of restart-looping.
                if files_changed {
                    self.act(svc, |sd, n| sd.restart(n), "restarted (config changed)")
                } else {
                    self.ensure_active(svc, true)
                }
            }
            ServiceState::Reloaded => {
                if files_changed {
                    self.act(svc, |sd, n| sd.reload(n), "reloaded (config changed)")
                } else {
                    self.ensure_active(svc, true)
                }
            }
        };
        changes.push(record);
    }

    /// Drive a unit to active/inactive, acting only on drift.
    fn ensure_active(&mut self, svc: &ServiceResource, want_active: bool) -> ChangeRecord {
        match self.systemd.is_active(&svc.name) {
            Ok(cur) if cur == want_active => ok(
                &svc.name,
                ChangeAction::Unchanged,
                if want_active {
                    "already active"
                } else {
                    "already inactive"
                },
            ),
            Ok(_) => {
                if want_active {
                    self.act(svc, |sd, n| sd.start(n), "started")
                } else {
                    self.act(svc, |sd, n| sd.stop(n), "stopped")
                }
            }
            Err(e) => fail(&svc.name, format!("is-active: {e}")),
        }
    }

    fn act(
        &mut self,
        svc: &ServiceResource,
        f: impl FnOnce(&mut dyn SystemdController, &str) -> io::Result<()>,
        note: &str,
    ) -> ChangeRecord {
        match f(self.systemd, &svc.name) {
            Ok(()) => ok(&svc.name, ChangeAction::Acted, note),
            Err(e) => fail(&svc.name, format!("{note}: {e}")),
        }
    }
}

fn ok(target: &str, action: ChangeAction, detail: &str) -> ChangeRecord {
    ChangeRecord {
        target: target.to_string(),
        action,
        detail: detail.to_string(),
    }
}

fn fail(target: &str, detail: String) -> ChangeRecord {
    ChangeRecord {
        target: target.to_string(),
        action: ChangeAction::Failed,
        detail,
    }
}

fn summarize(changes: &[ChangeRecord]) -> ApplyResult {
    if changes.iter().any(|c| c.action == ChangeAction::Failed) {
        ApplyResult::Failed
    } else if changes.iter().any(|c| c.action.is_change()) {
        ApplyResult::Changed
    } else {
        ApplyResult::Ok
    }
}

#[cfg(test)]
mod tests;