WP-11: contract and failing tests for package upgrades
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -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)),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
141
backend/crates/infrastructure/src/host/updater.rs
Normal file
141
backend/crates/infrastructure/src/host/updater.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user