WP-11: contract and failing tests for package upgrades
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:24:40 +02:00
parent b6ddb8889d
commit 1f56f015a2
18 changed files with 524 additions and 2 deletions

View File

@ -1,4 +1,5 @@
use async_trait::async_trait;
use domain::ports::LineSink;
use domain::DomainError;
#[derive(Clone, Debug, Default)]
@ -12,6 +13,13 @@ pub struct Output {
pub trait CommandRunner: Send + Sync {
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError>;
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError>;
/// Run a command and forward each output line (stdout and stderr) to `out`.
async fn run_streaming(
&self,
program: &str,
args: &[&str],
out: &dyn LineSink,
) -> Result<bool, DomainError>;
}
pub struct SystemCommandRunner;
@ -33,6 +41,15 @@ impl CommandRunner for SystemCommandRunner {
})
}
async fn run_streaming(
&self,
_program: &str,
_args: &[&str],
_out: &dyn LineSink,
) -> Result<bool, DomainError> {
todo!()
}
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError> {
match tokio::fs::read_to_string(path).await {
Ok(s) => Ok(Some(s)),

View File

@ -229,6 +229,14 @@ Conf openssl (3.0.16-1~deb12u1 Debian-Security:12/stable-security [amd64])\n";
success: true,
})
}
async fn run_streaming(
&self,
_p: &str,
_a: &[&str],
_o: &dyn domain::ports::LineSink,
) -> Result<bool, DomainError> {
unreachable!()
}
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError> {
Ok(match path {
"/etc/os-release" => Some(

View File

@ -66,3 +66,27 @@ impl HostInspector for FakeHostInspector {
])
}
}
/// Pretends to upgrade packages (FAKE_HOST=true).
pub struct FakeHostUpdater;
#[async_trait]
impl domain::ports::HostUpdater for FakeHostUpdater {
async fn upgrade(
&self,
packages: &[String],
out: &dyn domain::ports::LineSink,
) -> Result<(), DomainError> {
out.line("Reading package lists... Done");
out.line("Building dependency tree... Done");
let list = if packages.is_empty() {
"all upgradable packages".to_string()
} else {
packages.join(" ")
};
out.line(&format!("The following packages will be upgraded: {list}"));
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
out.line("Setting up packages ... Done");
Ok(())
}
}

View File

@ -2,7 +2,9 @@
pub mod command;
pub mod debian;
pub mod fake;
pub mod updater;
pub use command::{CommandRunner, Output, SystemCommandRunner};
pub use debian::DebianInspector;
pub use fake::FakeHostInspector;
pub use fake::{FakeHostInspector, FakeHostUpdater};
pub use updater::DebianUpdater;

View File

@ -0,0 +1,141 @@
//! Applies apt upgrades on a Debian host.
use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{HostUpdater, LineSink};
use domain::DomainError;
use super::command::CommandRunner;
pub struct DebianUpdater {
runner: Arc<dyn CommandRunner>,
}
impl DebianUpdater {
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
Self { runner }
}
}
#[async_trait]
impl HostUpdater for DebianUpdater {
async fn upgrade(&self, _packages: &[String], _out: &dyn LineSink) -> Result<(), DomainError> {
let _ = &self.runner;
todo!()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct Recording {
calls: Mutex<Vec<(String, Vec<String>)>>,
fail_install: bool,
}
#[async_trait]
impl CommandRunner for Recording {
async fn run(
&self,
program: &str,
args: &[&str],
) -> Result<super::super::Output, DomainError> {
self.calls
.lock()
.unwrap()
.push((program.into(), args.iter().map(|s| s.to_string()).collect()));
Ok(super::super::Output {
success: true,
..Default::default()
})
}
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
Ok(None)
}
async fn run_streaming(
&self,
program: &str,
args: &[&str],
out: &dyn LineSink,
) -> Result<bool, DomainError> {
self.calls
.lock()
.unwrap()
.push((program.into(), args.iter().map(|s| s.to_string()).collect()));
out.line("Reading package lists...");
Ok(!(self.fail_install && args.contains(&"install")))
}
}
#[derive(Default)]
struct Lines(Mutex<Vec<String>>);
impl LineSink for Lines {
fn line(&self, t: &str) {
self.0.lock().unwrap().push(t.into());
}
}
#[tokio::test]
async fn selected_packages_use_only_upgrade_after_apt_update() {
let r = Arc::new(Recording::default());
let out = Lines::default();
DebianUpdater::new(r.clone())
.upgrade(&["openssl".into(), "curl".into()], &out)
.await
.unwrap();
let calls = r.calls.lock().unwrap();
assert_eq!(calls[0].0, "apt-get");
assert_eq!(calls[0].1[0], "update");
assert_eq!(calls[1].0, "apt-get");
let install = calls[1].1.join(" ");
assert!(install.contains("install --only-upgrade"), "{install}");
assert!(install.contains("-y"), "{install}");
assert!(install.ends_with("openssl curl"), "{install}");
assert!(out
.0
.lock()
.unwrap()
.iter()
.any(|l| l.contains("Reading package lists")));
}
#[tokio::test]
async fn empty_selection_runs_dist_upgrade() {
let r = Arc::new(Recording::default());
DebianUpdater::new(r.clone())
.upgrade(&[], &Lines::default())
.await
.unwrap();
let calls = r.calls.lock().unwrap();
assert!(calls[1].1.contains(&"dist-upgrade".to_string()));
}
#[tokio::test]
async fn non_zero_exit_is_an_error() {
let r = Arc::new(Recording {
fail_install: true,
..Default::default()
});
let err = DebianUpdater::new(r)
.upgrade(&["x1".into()], &Lines::default())
.await
.unwrap_err();
assert!(matches!(err, DomainError::Unavailable(_)));
}
#[tokio::test]
async fn system_runner_streams_real_output() {
let out = Lines::default();
let ok = super::super::SystemCommandRunner
.run_streaming("sh", &["-c", "echo one; echo two 1>&2; exit 3"], &out)
.await
.unwrap();
assert!(!ok);
let mut lines = out.0.lock().unwrap().clone();
lines.sort();
assert_eq!(lines, vec!["one", "two"]);
}
}

View File

@ -9,7 +9,9 @@ pub mod token;
pub use cipher::AesGcmCipher;
pub use db::{connect, DbPool};
pub use host::{DebianInspector, FakeHostInspector, SystemCommandRunner};
pub use host::{
DebianInspector, DebianUpdater, FakeHostInspector, FakeHostUpdater, SystemCommandRunner,
};
pub use mail::LettreMailer;
pub use password::Argon2Hasher;
pub use sqlite::SqliteInventory;