Skip to content

Commit 8d95522

Browse files
committed
work on integration testing framework (#41)
1 parent 8513753 commit 8d95522

File tree

10 files changed

+220
-114
lines changed

10 files changed

+220
-114
lines changed

.cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"Chunker",
3939
"cmdline",
4040
"composability",
41+
"cpus",
4142
"discoverability",
4243
"Discoverability",
4344
"editenv",

crates/reportify/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ use std::{
276276
any::Any,
277277
error::Error as StdError,
278278
fmt::{Debug, Display},
279+
future::Future,
279280
};
280281

281282
use backtrace::BacktraceImpl;
@@ -327,7 +328,7 @@ impl<E: 'static + StdError + Send> Error for E {
327328
}
328329
}
329330

330-
trait AnyReport {
331+
trait AnyReport: Send {
331332
fn error(&self) -> &dyn Error;
332333

333334
fn meta(&self) -> &ReportMeta;

crates/rugpi-bakery/src/bake/image.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::{
2525
rpi_uboot::initialize_uboot, Target,
2626
},
2727
project::images::{self, grub_efi_image_layout, pi_image_layout, ImageConfig, ImageLayout},
28-
utils::prelude::*,
28+
utils::{caching::mtime, prelude::*},
2929
BakeryResult,
3030
};
3131

@@ -37,6 +37,15 @@ pub fn make_image(config: &ImageConfig, src: &Path, image: &Path) -> BakeryResul
3737
fs::create_dir_all(parent).ok();
3838
}
3939

40+
if image.exists() {
41+
let src_mtime = mtime(src).whatever("unable to get image mtime")?;
42+
let image_mtime = mtime(image).whatever("unable to get image mtime")?;
43+
if src_mtime <= image_mtime {
44+
info!("Image is newer than sources.");
45+
return Ok(());
46+
}
47+
}
48+
4049
// Initialize system root directory from provided TAR file.
4150
info!("Extracting layer.");
4251
run!(["tar", "-xf", src, "-C", &bundle_dir]).whatever("unable to extract layer")?;

crates/rugpi-bakery/src/main.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::{
22
collections::HashMap,
33
convert::Infallible,
4-
ffi::{CStr, CString},
4+
ffi::{CStr, CString, OsStr},
55
fs,
66
path::{Path, PathBuf},
77
};
@@ -15,6 +15,7 @@ use project::{
1515
use reportify::{bail, Report, ResultExt};
1616
use rugpi_common::fsutils::copy_recursive;
1717
use serde::Deserialize;
18+
use test::RugpiTestError;
1819

1920
pub mod bake;
2021
pub mod project;
@@ -93,7 +94,7 @@ pub enum BakeCommand {
9394
/// The `test` command.
9495
#[derive(Debug, Parser)]
9596
pub struct TestCommand {
96-
case: PathBuf,
97+
workflows: Vec<String>,
9798
}
9899

99100
/// The `bake` command.
@@ -233,11 +234,38 @@ fn main() -> BakeryResult<()> {
233234
}
234235
}
235236
Command::Test(test_command) => {
237+
let project = load_project(&args)?;
236238
tokio::runtime::Builder::new_multi_thread()
237239
.enable_all()
238240
.build()
239241
.unwrap()
240-
.block_on(test::main(&test_command.case))
242+
.block_on(async move {
243+
let mut workflows = Vec::new();
244+
if test_command.workflows.is_empty() {
245+
let mut read_dir = tokio::fs::read_dir(project.dir.join("tests"))
246+
.await
247+
.whatever("unable to scan for test workflows")?;
248+
while let Some(entry) = read_dir
249+
.next_entry()
250+
.await
251+
.whatever("unable to read entry")?
252+
{
253+
let path = entry.path();
254+
if path.extension() == Some(OsStr::new("toml")) {
255+
workflows.push(path);
256+
}
257+
}
258+
} else {
259+
for name in &test_command.workflows {
260+
workflows
261+
.push(project.dir.join("tests").join(name).with_extension("toml"));
262+
}
263+
};
264+
for workflow in workflows {
265+
test::main(&project, &workflow).await?;
266+
}
267+
<Result<(), Report<RugpiTestError>>>::Ok(())
268+
})
241269
.whatever("unable to run test")?;
242270
}
243271
}

crates/rugpi-bakery/src/project/mod.rs

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
//! In-memory representation of Rugpi Bakery projects.
22
33
use std::{
4-
cell::OnceCell,
54
path::{Path, PathBuf},
6-
sync::Arc,
5+
sync::{Arc, OnceLock},
76
};
87

98
use reportify::ResultExt;
109

1110
use self::{config::BakeryConfig, library::Library, repositories::ProjectRepositories};
12-
use crate::{utils::prelude::*, BakeryResult};
11+
use crate::BakeryResult;
1312

1413
pub mod config;
1514
pub mod images;
@@ -19,7 +18,7 @@ pub mod recipes;
1918
pub mod repositories;
2019

2120
/// A project.
22-
#[derive(Debug)]
21+
#[derive(Debug, Clone)]
2322
#[non_exhaustive]
2423
pub struct Project {
2524
/// The configuration of the project.
@@ -33,30 +32,37 @@ pub struct Project {
3332
impl Project {
3433
/// The repositories of the project.
3534
pub fn repositories(&self) -> BakeryResult<&Arc<ProjectRepositories>> {
36-
self.lazy
37-
.repositories
38-
.try_get_or_init(|| ProjectRepositories::load(self).map(Arc::new))
39-
.whatever("loading repositories")
35+
if let Some(repositories) = self.lazy.repositories.get() {
36+
return Ok(repositories);
37+
}
38+
let repositories = ProjectRepositories::load(self)
39+
.map(Arc::new)
40+
.whatever("loading repositories")?;
41+
let _ = self.lazy.repositories.set(repositories);
42+
Ok(self.lazy.repositories.get().unwrap())
4043
}
4144

4245
/// The library of the project.
4346
pub fn library(&self) -> BakeryResult<&Arc<Library>> {
44-
self.lazy.library.try_get_or_init(|| {
45-
let repositories = self.repositories()?.clone();
46-
Library::load(repositories)
47-
.map(Arc::new)
48-
.whatever("loading library")
49-
})
47+
if let Some(library) = self.lazy.library.get() {
48+
return Ok(library);
49+
}
50+
let repositories = self.repositories()?.clone();
51+
let library = Library::load(repositories)
52+
.map(Arc::new)
53+
.whatever("loading library")?;
54+
let _ = self.lazy.library.set(library);
55+
Ok(self.lazy.library.get().unwrap())
5056
}
5157
}
5258

5359
/// Lazily initialized fields of [`Project`].
54-
#[derive(Debug, Default)]
60+
#[derive(Debug, Default, Clone)]
5561
struct ProjectLazy {
5662
/// The repositories of the project.
57-
repositories: OnceCell<Arc<ProjectRepositories>>,
63+
repositories: OnceLock<Arc<ProjectRepositories>>,
5864
/// The library of the project.
59-
library: OnceCell<Arc<Library>>,
65+
library: OnceLock<Arc<Library>>,
6066
}
6167

6268
/// Project loader.

crates/rugpi-bakery/src/test/mod.rs

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,77 @@
11
use std::{path::Path, time::Duration};
22

3-
use case::{TestCase, TestStep};
43
use reportify::{bail, Report, ResultExt};
54
use rugpi_cli::info;
6-
use tokio::fs;
5+
use tokio::{fs, task::spawn_blocking};
6+
use workflow::{TestStep, TestWorkflow};
7+
8+
use crate::{bake, project::Project};
79

8-
pub mod case;
910
pub mod qemu;
11+
pub mod workflow;
1012

1113
reportify::new_whatever_type! {
1214
RugpiTestError
1315
}
1416

1517
pub type RugpiTestResult<T> = Result<T, Report<RugpiTestError>>;
1618

17-
pub async fn main(case: &Path) -> RugpiTestResult<()> {
18-
let case = toml::from_str::<TestCase>(
19-
&fs::read_to_string(&case)
19+
pub async fn main(project: &Project, workflow: &Path) -> RugpiTestResult<()> {
20+
let workflow = toml::from_str::<TestWorkflow>(
21+
&fs::read_to_string(&workflow)
2022
.await
21-
.whatever("unable to read test case")?,
23+
.whatever("unable to read test workflow")?,
2224
)
23-
.whatever("unable to parse test case")?;
24-
25-
let vm = qemu::start(&case.vm).await?;
26-
27-
info!("VM started");
28-
29-
for step in &case.steps {
30-
match step {
31-
case::TestStep::Reboot => todo!(),
32-
case::TestStep::Copy { .. } => todo!(),
33-
case::TestStep::Run {
34-
script,
35-
stdin,
36-
may_fail,
37-
} => {
38-
info!("running script");
39-
vm.wait_for_ssh()
40-
.await
41-
.whatever("unable to connect to VM via SSH")?;
42-
if let Err(report) = vm
43-
.run_script(script, stdin.as_ref().map(|p| p.as_ref()))
44-
.await
45-
.whatever::<RugpiTestError, _>("unable to run script")
46-
{
47-
if may_fail.unwrap_or(false) {
48-
eprintln!("ignoring error while executing script:\n{report:?}");
49-
} else {
50-
bail!("error during test")
25+
.whatever("unable to parse test workflow")?;
26+
27+
for system in &workflow.systems {
28+
let output = Path::new("build/images")
29+
.join(&system.disk_image)
30+
.with_extension("img");
31+
let project = project.clone();
32+
let disk_image = system.disk_image.clone();
33+
{
34+
let output = output.clone();
35+
spawn_blocking(move || bake::bake_image(&project, &disk_image, &output))
36+
.await
37+
.whatever("error baking image")?
38+
.whatever("error baking image")?;
39+
}
40+
41+
let vm = qemu::start(&output.to_string_lossy(), system).await?;
42+
43+
info!("VM started");
44+
45+
for step in &workflow.steps {
46+
match step {
47+
workflow::TestStep::Run {
48+
script,
49+
stdin,
50+
may_fail,
51+
} => {
52+
info!("running script");
53+
vm.wait_for_ssh()
54+
.await
55+
.whatever("unable to connect to VM via SSH")?;
56+
if let Err(report) = vm
57+
.run_script(script, stdin.as_ref().map(|p| p.as_ref()))
58+
.await
59+
.whatever::<RugpiTestError, _>("unable to run script")
60+
{
61+
if may_fail.unwrap_or(false) {
62+
eprintln!("ignoring error while executing script:\n{report:?}");
63+
} else {
64+
bail!("error during test")
65+
}
5166
}
5267
}
53-
}
54-
TestStep::Wait { duration_secs } => {
55-
info!("waiting for {duration_secs} seconds");
56-
tokio::time::sleep(Duration::from_secs_f64(*duration_secs)).await;
68+
TestStep::Wait { duration } => {
69+
info!("waiting for {duration} seconds");
70+
tokio::time::sleep(Duration::from_secs_f64(*duration)).await;
71+
}
5772
}
5873
}
5974
}
75+
6076
Ok(())
6177
}

crates/rugpi-bakery/src/test/qemu.rs

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ use tokio::{
1818
time,
1919
};
2020

21-
use super::{case::VmConfig, RugpiTestResult};
21+
use super::{workflow::TestSystemConfig, RugpiTestResult};
2222

2323
pub struct Vm {
2424
#[expect(dead_code, reason = "not currently used")]
2525
child: Child,
2626
ssh_session: Mutex<Option<Handle<SshHandler>>>,
2727
sftp_session: Mutex<Option<SftpSession>>,
2828
#[expect(dead_code, reason = "not currently used")]
29-
vm_config: VmConfig,
29+
vm_config: TestSystemConfig,
3030
private_key: Arc<PrivateKey>,
3131
}
3232

@@ -158,20 +158,20 @@ impl Vm {
158158
}
159159
}
160160

161-
pub async fn start(config: &VmConfig) -> RugpiTestResult<Vm> {
162-
let private_key = load_secret_key(&config.private_key, None)
161+
pub async fn start(image_file: &str, config: &TestSystemConfig) -> RugpiTestResult<Vm> {
162+
let private_key = load_secret_key(&config.ssh.private_key, None)
163163
.whatever("unable to load private SSH key")
164-
.with_info(|_| format!("path: {:?}", config.private_key))?;
164+
.with_info(|_| format!("path: {:?}", config.ssh.private_key))?;
165165
fs::create_dir_all(".rugpi/")
166166
.await
167167
.whatever("unable to create .rugpi directory")?;
168168
if !Command::new("qemu-img")
169169
.args(&["create", "-f", "qcow2", "-F", "raw", "-o"])
170-
.arg(format!(
171-
"backing_file=../{}",
172-
config.image.to_string_lossy()
173-
))
174-
.args(&[".rugpi/vm-image.img", "48G"])
170+
.arg(format!("backing_file=../{}", image_file))
171+
.args(&[
172+
".rugpi/vm-image.img",
173+
config.disk_size.as_deref().unwrap_or("40G"),
174+
])
175175
.spawn()
176176
.whatever("unable to create VM image")?
177177
.wait()
@@ -207,30 +207,22 @@ pub async fn start(config: &VmConfig) -> RugpiTestResult<Vm> {
207207
]);
208208
command
209209
.kill_on_drop(true)
210-
.stdout(if config.stdout.is_some() {
211-
Stdio::piped()
212-
} else {
213-
Stdio::null()
214-
})
215-
.stderr(if config.stderr.is_some() {
216-
Stdio::piped()
217-
} else {
218-
Stdio::null()
219-
})
210+
.stdout(Stdio::piped())
211+
.stderr(Stdio::piped())
220212
.stdin(Stdio::null());
221213
let mut child = command.spawn().whatever("unable to spawn Qemu")?;
222-
if let Some(stdout) = &config.stdout {
214+
if let Some(stdout) = Some("build/vm-stdout.log") {
223215
let mut stdout_log = fs::File::create(stdout)
224216
.await
225217
.whatever("unable to create stdout log file")?;
226218
let mut stdout = child.stdout.take().expect("we used Stdio::piped");
227219
tokio::spawn(async move { io::copy(&mut stdout, &mut stdout_log).await });
228220
}
229-
if let Some(stderr) = &config.stderr {
221+
if let Some(stderr) = Some("build/vm-stderr.log") {
230222
let mut stderr_log = fs::File::create(stderr)
231223
.await
232224
.whatever("unable to create stderr log file")?;
233-
let mut stderr = child.stdout.take().expect("we used Stdio::piped");
225+
let mut stderr = child.stderr.take().expect("we used Stdio::piped");
234226
tokio::spawn(async move { io::copy(&mut stderr, &mut stderr_log).await });
235227
}
236228
// We give Qemu some time to start before checking it's exit status.

0 commit comments

Comments
 (0)