//! Reconciler unit tests over in-memory fakes.
//!
//! The central property under test is idempotency: applying a manifest brings
//! the system to the desired state (reporting `Changed`), and applying it again
//! against the now-converged system reports `Ok` with no actions.
use std::cell::RefCell;
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use devendra_proto::{
ApplyResult, ChangeAction, DirResource, FileResource, FileState, Manifest, ServiceResource,
ServiceState, content_hash,
};
use super::*;
/// In-memory file entry.
#[derive(Clone)]
struct FakeFile {
content: Vec<u8>,
mode: u32,
uid: u32,
gid: u32,
}
#[derive(Default)]
struct FakeFs {
files: HashMap<PathBuf, FakeFile>,
/// Directories: mode/uid/gid tracked via FakeFile (content unused).
dirs: HashMap<PathBuf, FakeFile>,
users: HashMap<String, u32>,
groups: HashMap<String, u32>,
}
impl FakeFs {
fn with_ids() -> Self {
let mut fs = FakeFs::default();
fs.users.insert("root".into(), 0);
fs.users.insert("app".into(), 1000);
fs.groups.insert("root".into(), 0);
fs.groups.insert("app".into(), 1000);
fs
}
}
impl FileSystem for FakeFs {
fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
Ok(self.files.get(path).map(|f| f.content.clone()))
}
fn metadata(&self, path: &Path) -> io::Result<Option<FileMeta>> {
let entry = self
.files
.get(path)
.map(|f| (f, false))
.or_else(|| self.dirs.get(path).map(|d| (d, true)));
Ok(entry.map(|(e, is_dir)| FileMeta {
mode: e.mode,
uid: e.uid,
gid: e.gid,
is_dir,
}))
}
fn write(&mut self, path: &Path, content: &[u8]) -> io::Result<()> {
let entry = self.files.entry(path.to_path_buf()).or_insert(FakeFile {
content: Vec::new(),
mode: 0o644,
uid: 0,
gid: 0,
});
entry.content = content.to_vec();
Ok(())
}
fn set_mode(&mut self, path: &Path, mode: u32) -> io::Result<()> {
let entry = self
.files
.get_mut(path)
.or_else(|| self.dirs.get_mut(path))
.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
entry.mode = mode;
Ok(())
}
fn chown(&mut self, path: &Path, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
let f = self
.files
.get_mut(path)
.or_else(|| self.dirs.get_mut(path))
.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
if let Some(u) = uid {
f.uid = u;
}
if let Some(g) = gid {
f.gid = g;
}
Ok(())
}
fn create_dir(&mut self, path: &Path) -> io::Result<()> {
self.dirs.entry(path.to_path_buf()).or_insert(FakeFile {
content: Vec::new(),
mode: 0o755,
uid: 0,
gid: 0,
});
Ok(())
}
fn remove_dir(&mut self, path: &Path) -> io::Result<()> {
self.dirs
.remove(path)
.map(|_| ())
.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
}
fn remove(&mut self, path: &Path) -> io::Result<()> {
self.files
.remove(path)
.map(|_| ())
.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
}
fn resolve_user(&self, name: &str) -> io::Result<u32> {
self.users
.get(name)
.copied()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user"))
}
fn resolve_group(&self, name: &str) -> io::Result<u32> {
self.groups
.get(name)
.copied()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no group"))
}
}
/// Records systemd calls and lets a test preset current active/enabled state.
#[derive(Default)]
struct FakeSystemd {
active: RefCell<HashMap<String, bool>>,
enabled: RefCell<HashMap<String, bool>>,
log: RefCell<Vec<String>>,
}
impl FakeSystemd {
fn set_active(&self, unit: &str, v: bool) {
self.active.borrow_mut().insert(unit.into(), v);
}
fn set_enabled(&self, unit: &str, v: bool) {
self.enabled.borrow_mut().insert(unit.into(), v);
}
fn calls(&self) -> Vec<String> {
self.log.borrow().clone()
}
}
impl SystemdController for FakeSystemd {
fn daemon_reload(&mut self) -> io::Result<()> {
self.log.borrow_mut().push("daemon-reload".into());
Ok(())
}
fn is_active(&self, unit: &str) -> io::Result<bool> {
Ok(*self.active.borrow().get(unit).unwrap_or(&false))
}
fn is_enabled(&self, unit: &str) -> io::Result<bool> {
Ok(*self.enabled.borrow().get(unit).unwrap_or(&false))
}
fn start(&mut self, unit: &str) -> io::Result<()> {
self.log.borrow_mut().push(format!("start {unit}"));
self.active.borrow_mut().insert(unit.into(), true);
Ok(())
}
fn stop(&mut self, unit: &str) -> io::Result<()> {
self.log.borrow_mut().push(format!("stop {unit}"));
self.active.borrow_mut().insert(unit.into(), false);
Ok(())
}
fn restart(&mut self, unit: &str) -> io::Result<()> {
self.log.borrow_mut().push(format!("restart {unit}"));
self.active.borrow_mut().insert(unit.into(), true);
Ok(())
}
fn reload(&mut self, unit: &str) -> io::Result<()> {
self.log.borrow_mut().push(format!("reload {unit}"));
Ok(())
}
fn enable(&mut self, unit: &str) -> io::Result<()> {
self.log.borrow_mut().push(format!("enable {unit}"));
self.enabled.borrow_mut().insert(unit.into(), true);
Ok(())
}
fn disable(&mut self, unit: &str) -> io::Result<()> {
self.log.borrow_mut().push(format!("disable {unit}"));
self.enabled.borrow_mut().insert(unit.into(), false);
Ok(())
}
}
/// Records package operations and lets a test preset installed state.
#[derive(Default)]
struct FakePackages {
installed: RefCell<HashMap<String, bool>>,
log: RefCell<Vec<String>>,
}
impl FakePackages {
fn set_installed(&self, name: &str, v: bool) {
self.installed.borrow_mut().insert(name.into(), v);
}
fn calls(&self) -> Vec<String> {
self.log.borrow().clone()
}
}
impl PackageManager for FakePackages {
fn is_installed(&self, name: &str) -> io::Result<bool> {
Ok(*self.installed.borrow().get(name).unwrap_or(&false))
}
fn install(&mut self, name: &str) -> io::Result<()> {
self.log.borrow_mut().push(format!("install {name}"));
self.installed.borrow_mut().insert(name.into(), true);
Ok(())
}
fn remove(&mut self, name: &str) -> io::Result<()> {
self.log.borrow_mut().push(format!("remove {name}"));
self.installed.borrow_mut().insert(name.into(), false);
Ok(())
}
fn backend(&self) -> &str {
"fake"
}
}
/// Content source backed by a name->bytes map keyed on destination path.
struct MapContent(HashMap<String, Vec<u8>>);
impl ContentSource for MapContent {
fn fetch(&self, resource: &FileResource) -> io::Result<Vec<u8>> {
self.0
.get(&resource.path)
.cloned()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no content"))
}
}
fn present_file(path: &str, body: &[u8], mode: Option<u32>) -> FileResource {
FileResource {
path: path.into(),
state: FileState::Present,
content_blake3: Some(content_hash(body)),
mode,
owner: None,
group: None,
}
}
/// Apply twice; assert first pass changes and second pass is a clean no-op.
fn assert_idempotent(
manifest: &Manifest,
fs: &mut FakeFs,
sd: &mut FakeSystemd,
pkg: &mut FakePackages,
content: &MapContent,
) {
let first = Reconciler {
fs,
systemd: sd,
packages: pkg,
content,
}
.apply(manifest);
assert_ne!(
first.result,
ApplyResult::Failed,
"first apply failed: {:?}",
first.changes
);
sd.log.borrow_mut().clear();
let second = Reconciler {
fs,
systemd: sd,
packages: pkg,
content,
}
.apply(manifest);
assert_eq!(
second.result,
ApplyResult::Ok,
"second apply was not a clean no-op: {:?}",
second.changes
);
assert!(
second.changes.iter().all(|c| !c.action.is_change()),
"second apply reported changes: {:?}",
second.changes
);
assert!(
sd.calls().is_empty(),
"second apply touched systemd: {:?}",
sd.calls()
);
}
#[test]
fn installs_quadlet_and_reloads_then_is_idempotent() {
let body = b"[Container]\nImage=docker.io/library/nginx\n";
let path = "/etc/containers/systemd/web.container";
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![],
directories: vec![],
files: vec![present_file(path, body, Some(0o644))],
services: vec![ServiceResource {
name: "web.service".into(),
enabled: None,
state: Some(ServiceState::Started),
}],
};
let content = MapContent(HashMap::from([(path.to_string(), body.to_vec())]));
let mut fs = FakeFs::with_ids();
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
// First apply: installs the quadlet, daemon-reloads, starts the service.
let first = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(first.result, ApplyResult::Changed);
assert!(sd.calls().contains(&"daemon-reload".to_string()));
assert!(sd.calls().contains(&"start web.service".to_string()));
// File landed with correct content + mode.
assert_eq!(fs.files[Path::new(path)].content, body);
assert_eq!(fs.files[Path::new(path)].mode, 0o644);
assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}
#[test]
fn drifted_content_is_rewritten() {
let body = b"good\n";
let path = "/etc/myapp/app.conf";
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 5,
packages: vec![],
directories: vec![],
files: vec![present_file(path, body, Some(0o600))],
services: vec![],
};
let content = MapContent(HashMap::from([(path.to_string(), body.to_vec())]));
let mut fs = FakeFs::with_ids();
// Pre-existing wrong content.
fs.files.insert(
PathBuf::from(path),
FakeFile {
content: b"stale\n".to_vec(),
mode: 0o600,
uid: 0,
gid: 0,
},
);
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
let r = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r.result, ApplyResult::Changed);
assert_eq!(r.changes[0].action, ChangeAction::Updated);
assert_eq!(fs.files[Path::new(path)].content, body);
// Non-systemd path: no daemon-reload.
assert!(!sd.calls().contains(&"daemon-reload".to_string()));
assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}
#[test]
fn absent_file_is_removed_then_stays_absent() {
let path = "/etc/myapp/old.conf";
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 2,
packages: vec![],
directories: vec![],
files: vec![FileResource {
path: path.into(),
state: FileState::Absent,
content_blake3: None,
mode: None,
owner: None,
group: None,
}],
services: vec![],
};
let content = MapContent(HashMap::new());
let mut fs = FakeFs::with_ids();
fs.files.insert(
PathBuf::from(path),
FakeFile {
content: b"x".to_vec(),
mode: 0o644,
uid: 0,
gid: 0,
},
);
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
let r = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r.result, ApplyResult::Changed);
assert_eq!(r.changes[0].action, ChangeAction::Removed);
assert!(!fs.files.contains_key(Path::new(path)));
assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}
#[test]
fn ownership_is_applied_and_idempotent() {
let body = b"data\n";
let path = "/etc/myapp/owned.conf";
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![],
directories: vec![],
files: vec![FileResource {
path: path.into(),
state: FileState::Present,
content_blake3: Some(content_hash(body)),
mode: Some(0o640),
owner: Some("app".into()),
group: Some("app".into()),
}],
services: vec![],
};
let content = MapContent(HashMap::from([(path.to_string(), body.to_vec())]));
let mut fs = FakeFs::with_ids();
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
let r = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r.result, ApplyResult::Changed);
let f = &fs.files[Path::new(path)];
assert_eq!((f.uid, f.gid), (1000, 1000));
assert_eq!(f.mode, 0o640);
assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}
#[test]
fn restart_only_fires_when_config_changes() {
let body = b"[Container]\nImage=x\n";
let path = "/etc/containers/systemd/api.container";
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![],
directories: vec![],
files: vec![present_file(path, body, Some(0o644))],
services: vec![ServiceResource {
name: "api.service".into(),
enabled: Some(true),
state: Some(ServiceState::Restarted),
}],
};
let content = MapContent(HashMap::from([(path.to_string(), body.to_vec())]));
let mut fs = FakeFs::with_ids();
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
// Pass 1: file is new -> config changed -> restart fires.
let r1 = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r1.result, ApplyResult::Changed);
assert!(sd.calls().contains(&"restart api.service".to_string()));
assert!(sd.calls().contains(&"enable api.service".to_string()));
// Pass 2: nothing changed -> no restart, unit already active -> unchanged.
sd.log.borrow_mut().clear();
let r2 = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r2.result, ApplyResult::Ok);
assert!(
!sd.calls().iter().any(|c| c.starts_with("restart")),
"restart should not fire on a clean pass: {:?}",
sd.calls()
);
}
#[test]
fn already_active_service_is_left_alone() {
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![],
directories: vec![],
files: vec![],
services: vec![ServiceResource {
name: "db.service".into(),
enabled: Some(true),
state: Some(ServiceState::Started),
}],
};
let content = MapContent(HashMap::new());
let mut fs = FakeFs::with_ids();
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
sd.set_active("db.service", true);
sd.set_enabled("db.service", true);
let r = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r.result, ApplyResult::Ok);
assert!(
sd.calls().is_empty(),
"unexpected systemd calls: {:?}",
sd.calls()
);
}
#[test]
fn content_hash_mismatch_is_reported_as_failure() {
let path = "/etc/myapp/x.conf";
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![],
directories: vec![],
// Manifest claims one hash, but the content source will return other bytes.
files: vec![present_file(path, b"expected\n", None)],
services: vec![],
};
let content = MapContent(HashMap::from([(path.to_string(), b"WRONG".to_vec())]));
let mut fs = FakeFs::with_ids();
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
let r = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r.result, ApplyResult::Failed);
assert_eq!(r.changes[0].action, ChangeAction::Failed);
assert!(r.changes[0].detail.contains("hash mismatch"));
}
#[test]
fn directory_is_created_then_idempotent() {
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![],
directories: vec![DirResource {
path: "/mnt/hass".into(),
state: FileState::Present,
mode: Some(0o750),
owner: Some("app".into()),
group: None,
}],
files: vec![],
services: vec![],
};
let content = MapContent(HashMap::new());
let mut fs = FakeFs::with_ids();
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
// First apply creates the directory with the requested mode + owner.
let r = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r.result, ApplyResult::Changed);
assert_eq!(r.changes[0].action, ChangeAction::Created);
let meta = fs.metadata(Path::new("/mnt/hass")).unwrap().unwrap();
assert!(meta.is_dir);
assert_eq!(meta.mode, 0o750);
assert_eq!(meta.uid, 1000);
assert_idempotent(&manifest, &mut fs, &mut sd, &mut pkg, &content);
}
#[test]
fn directory_conflicting_with_a_file_fails() {
let path = "/mnt/hass";
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![],
directories: vec![DirResource {
path: path.into(),
state: FileState::Present,
mode: None,
owner: None,
group: None,
}],
files: vec![],
services: vec![],
};
let content = MapContent(HashMap::new());
let mut fs = FakeFs::with_ids();
// A regular file already occupies the path.
fs.files.insert(
PathBuf::from(path),
FakeFile {
content: b"x".to_vec(),
mode: 0o644,
uid: 0,
gid: 0,
},
);
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
let r = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(r.result, ApplyResult::Failed);
assert!(r.changes[0].detail.contains("not a directory"));
}
#[test]
fn packages_install_and_remove_then_idempotent() {
let manifest = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![
devendra_proto::PackageResource {
name: "mosquitto".into(),
state: FileState::Present,
},
devendra_proto::PackageResource {
name: "telnet".into(),
state: FileState::Absent,
},
],
directories: vec![],
files: vec![],
services: vec![],
};
let content = MapContent(HashMap::new());
let mut fs = FakeFs::with_ids();
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
// mosquitto not yet installed; telnet is present and should be removed.
pkg.set_installed("telnet", true);
let first = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(first.result, ApplyResult::Changed);
assert!(pkg.calls().contains(&"install mosquitto".to_string()));
assert!(pkg.calls().contains(&"remove telnet".to_string()));
// Second pass: both are in the desired state -> no package operations.
pkg.log.borrow_mut().clear();
let second = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply(&manifest);
assert_eq!(second.result, ApplyResult::Ok);
assert!(
pkg.calls().is_empty(),
"second pass touched packages: {:?}",
pkg.calls()
);
}
#[test]
fn pruning_undoes_dropped_file_and_service() {
let body = b"[Container]\nImage=nginx\n";
let path = "/etc/containers/systemd/web.container";
// Prior state managed a quadlet + service.
let prior = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![],
directories: vec![],
files: vec![present_file(path, body, Some(0o644))],
services: vec![ServiceResource {
name: "web.service".into(),
enabled: Some(true),
state: Some(ServiceState::Started),
}],
};
// New state drops both (as if the fragment was removed).
let new = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 2,
packages: vec![],
directories: vec![],
files: vec![],
services: vec![],
};
let content = MapContent(HashMap::new());
let mut fs = FakeFs::with_ids();
// The quadlet is currently installed; the service running + enabled.
fs.files.insert(
PathBuf::from(path),
FakeFile {
content: body.to_vec(),
mode: 0o644,
uid: 0,
gid: 0,
},
);
let mut sd = FakeSystemd::default();
sd.set_active("web.service", true);
sd.set_enabled("web.service", true);
let mut pkg = FakePackages::default();
let report = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply_with_prior(&new, Some(&prior));
assert_eq!(report.result, ApplyResult::Changed);
// Service stopped + disabled, quadlet removed, daemon-reload run.
assert!(sd.calls().contains(&"stop web.service".to_string()));
assert!(sd.calls().contains(&"disable web.service".to_string()));
assert!(sd.calls().contains(&"daemon-reload".to_string()));
assert!(
!fs.files.contains_key(Path::new(path)),
"quadlet should be removed"
);
// Second apply with prior == new: nothing left to prune.
sd.log.borrow_mut().clear();
let again = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply_with_prior(&new, Some(&new));
assert_eq!(again.result, ApplyResult::Ok);
assert!(
sd.calls().is_empty(),
"no prune on a stable state: {:?}",
sd.calls()
);
}
#[test]
fn pruning_never_removes_directories_or_packages() {
let prior = Manifest {
protocol_version: devendra_proto::PROTOCOL_VERSION,
generation: 1,
packages: vec![devendra_proto::PackageResource {
name: "mosquitto".into(),
state: FileState::Present,
}],
directories: vec![DirResource {
path: "/mnt/data".into(),
state: FileState::Present,
mode: None,
owner: None,
group: None,
}],
files: vec![],
services: vec![],
};
let new = Manifest::empty(2);
let content = MapContent(HashMap::new());
let mut fs = FakeFs::with_ids();
fs.dirs.insert(
PathBuf::from("/mnt/data"),
FakeFile {
content: vec![],
mode: 0o755,
uid: 0,
gid: 0,
},
);
let mut sd = FakeSystemd::default();
let mut pkg = FakePackages::default();
pkg.set_installed("mosquitto", true);
let report = Reconciler {
fs: &mut fs,
systemd: &mut sd,
packages: &mut pkg,
content: &content,
}
.apply_with_prior(&new, Some(&prior));
// Data dir preserved; package left installed.
assert!(
fs.dirs.contains_key(Path::new("/mnt/data")),
"dir must not be pruned"
);
assert!(
!pkg.calls().iter().any(|c| c.starts_with("remove")),
"package must not be pruned"
);
assert_eq!(report.result, ApplyResult::Ok);
}
neiam
/devendra
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
25.1 KiB · text
History
e832e77