//! 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;
neiam
/devendra
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
21.3 KiB · text
History
d69022d