Skip to content

Commit

Permalink
WIP: tests/inst: Add destructive test framework
Browse files Browse the repository at this point in the history
This adds infrastructure to the Rust test suite for destructive
tests, and adds a new `transactionality` test which runs
rpm-ostree in a loop (along with `ostree-finalize-staged`) and
repeatedly kills them.

The main goal here is to flush out any "logic errors".  I plan
to further extend this to reboots and then force poweroffs.
  • Loading branch information
cgwalters committed Jul 2, 2020
1 parent 1b770c5 commit d818375
Show file tree
Hide file tree
Showing 12 changed files with 498 additions and 60 deletions.
3 changes: 3 additions & 0 deletions Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ AM_DISTCHECK_CONFIGURE_FLAGS += \

GITIGNOREFILES = aclocal.m4 build-aux/ buildutil/*.m4 config.h.in gtk-doc.make

# Generated by coreos-assembler build-fast and kola
GITIGNOREFILES += fastbuild-*.qcow2 _kola_temp/

SUBDIRS += .

if ENABLE_GTK_DOC
Expand Down
11 changes: 8 additions & 3 deletions tests/inst/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@ edition = "2018"

[[bin]]
name = "ostree-test"
path = "src/insttest.rs"
path = "src/insttestmain.rs"

[dependencies]
clap = "2.32.0"
structopt = "0.2"
structopt = "0.3"
serde = "1.0.111"
serde_derive = "1.0.111"
serde_json = "1.0"
commandspec = "0.12.2"
anyhow = "1.0"
tempfile = "3.1.0"
gio = "0.8"
ostree = { version = "0.7.1", features = ["v2020_1"] }
libtest-mimic = "0.2.0"
libtest-mimic = "0.3.0"
twoway = "0.2.1"
hyper = "0.13"
futures = "0.3.4"
Expand All @@ -29,6 +32,7 @@ procspawn = "0.8"
proc-macro2 = "0.4"
quote = "0.6"
syn = "0.15"
rand = "0.7.3"
linkme = "0.2"

itest-macro = { path = "itest-macro" }
Expand All @@ -39,4 +43,5 @@ with-procspawn-tempdir = { git = "https://github.com/cgwalters/with-procspawn-te
# See https://github.com/tcr/commandspec/pulls?q=is%3Apr+author%3Acgwalters+
[patch.crates-io]
commandspec = { git = "https://github.com/cgwalters/commandspec", branch = 'walters-master' }
# https://github.com/LukasKalbertodt/libtest-mimic/pull/5
#commandspec = { path = "/var/srv/walters/src/github/tcr/commandspec" }
35 changes: 32 additions & 3 deletions tests/inst/itest-macro/src/itest-macro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,47 @@ use quote::quote;
#[proc_macro_attribute]
pub fn itest(attrs: TokenStream, input: TokenStream) -> TokenStream {
let attrs = syn::parse_macro_input!(attrs as syn::AttributeArgs);
if attrs.len() > 0 {
return syn::Error::new_spanned(&attrs[0], "itest takes no attributes")
if attrs.len() > 1 {
return syn::Error::new_spanned(&attrs[1], "itest takes zero or 1 attributes")
.to_compile_error()
.into();
}
let destructive = match attrs.get(0) {
Some(syn::NestedMeta::Meta(syn::Meta::NameValue(namevalue))) => {
if let Some(name) = namevalue.path.get_ident().map(|i| i.to_string()) {
if name == "destructive" {
true
} else {
return syn::Error::new_spanned(
&attrs[1],
format!("Unknown argument {}", name),
)
.to_compile_error()
.into();
}
} else {
false
}
}
Some(v) => {
return syn::Error::new_spanned(&v, "Unexpected argument")
.to_compile_error()
.into()
}
None => false,
};
let func = syn::parse_macro_input!(input as syn::ItemFn);
let fident = func.sig.ident.clone();
let varident = quote::format_ident!("ITEST_{}", fident);
let fidentstrbuf = format!(r#"{}"#, fident);
let fidentstr = syn::LitStr::new(&fidentstrbuf, Span::call_site());
let testident = if destructive {
quote::format_ident!("{}", "DESTRUCTIVE_TESTS")
} else {
quote::format_ident!("{}", "NONDESTRUCTIVE_TESTS")
};
let output = quote! {
#[linkme::distributed_slice(TESTS)]
#[linkme::distributed_slice(#testident)]
#[allow(non_upper_case_globals)]
static #varident : Test = Test {
name: #fidentstr,
Expand Down
259 changes: 259 additions & 0 deletions tests/inst/src/destructive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
//! Test that interrupting a deploy is safe
use anyhow::Result;
use commandspec::{sh_command, sh_execute};
use rand::Rng;
use std::{fs, path::Path};
use std::{process, thread, time};

use crate::rpmostree;
use crate::test::*;

const ORIGREF: &'static str = "origref";
const TESTREF: &'static str = "testref";
const OVLROOT: &'static str = "overlay-root";

/// TODO add readonly sysroot handling into base ostree
fn testinit() -> Result<()> {
assert!(std::path::Path::new("/run/ostree-booted").exists());
sh_execute!(
r"if ! test -w /sysroot; then
mount -o remount,rw /sysroot
fi"
)?;
Ok(())
}

enum Boot {
First,
Subsequent(u32),
}

fn boot() -> Result<Boot> {
let out = sh_command!("journalctl --list-boots | wc -l")
.unwrap()
.output()?
.stdout;
let v: u32 = std::str::from_utf8(&out)?.trim().parse()?;
match v {
0 => anyhow::bail!("Unexpected zero boots from journalctl --list-boots"),
1 => Ok(Boot::First),
n => Ok(Boot::Subsequent(n.checked_sub(1).unwrap())),
}
}

fn reboot() -> ! {
std::fs::write("/etc/kola-reboot", b"").expect("creating /etc/kola-reboot");
let ecode = 0;
std::process::exit(ecode);
}

// This is like https://github.com/coreos/coreos-assembler/blob/a83e4c5f1191197132eee4bf570b661ce7e6bf1b/src/cmd-build-fast#L74
fn consume_to_overlay_commit<P: AsRef<Path>, D: AsRef<Path>>(
repo: P,
base: &str,
branch: &str,
dir: D,
) -> Result<()> {
let repo = repo.as_ref();
let dir = dir.as_ref();
let etc = dir.join("etc");
let usretc = dir.join("usr/etc");
if etc.exists() {
assert!(!usretc.exists());
fs::create_dir_all(dir.join("usr"))?;
fs::rename(etc, usretc)?;
}
sh_execute!("ostree --repo={repo} commit --consume -b {branch} --base={base} --tree=dir={dir} --owner-uid 0 --owner-gid 0 --selinux-policy-from-base --link-checkout-speedup --no-bindings --no-xattrs",
repo = repo.to_str().unwrap(),
branch = branch,
base = base,
dir = dir.to_str().unwrap())?;
Ok(())
}

fn update_srv_repo<P: AsRef<Path>>(srvrepo: P) -> Result<()> {
let verpath = Path::new("ovl-version");
let v: u32 = if verpath.exists() {
let s = std::fs::read_to_string(&verpath)?;
let v: u32 = s.trim_end().parse()?;
v + 1
} else {
0
};
let ovldir = Path::new("ovl");
mkvroot(&ovldir, v)?;
write_file(&verpath, &format!("{}", v))?;
consume_to_overlay_commit(srvrepo, TESTREF, TESTREF, &ovldir)?;
Ok(())
}

/// Create an archive repository of current OS content. This is a bit expensive;
/// in the future we should try a trick using the `parent` property on this repo,
/// and then teach our webserver to redirect to the system for objects it doesn't
/// have.
fn generate_srv_repo<P: AsRef<Path>>(sysroot: &ostree::Sysroot, srvrepo: P) -> Result<()> {
let srvrepo = srvrepo.as_ref();
let booted = sysroot.get_booted_deployment().expect("booted deployment");
let booted_checksum = booted.get_csum().expect("booted csum");
let booted_checksum = booted_checksum.as_str();
sh_execute!(
r#"
ostree --repo={srvrepo} init --mode=archive
ostree --repo={srvrepo} pull-local /sysroot/ostree/repo {booted_checksum}"#,
srvrepo = srvrepo.to_str().unwrap(),
booted_checksum = booted_checksum
)?;
let ovlroot = Path::new(OVLROOT);
mkroot(&ovlroot)?;
consume_to_overlay_commit(srvrepo, booted_checksum, TESTREF, &ovlroot)?;
Ok(())
}

fn run_cycle() -> Result<()> {
sh_execute!(
r#"
systemctl reset-failed rpm-ostreed
systemctl reset-failed ostree-finalize-staged
rpm-ostree cleanup -pr
rpm-ostree rebase testrepo:testref
systemctl stop ostree-finalize-staged"#
)?;
Ok(())
}

fn time_cycle() -> Result<time::Duration> {
let start = time::Instant::now();
run_cycle()?;
let end = time::Instant::now();
Ok(end.duration_since(start))
}

enum DeployResult {
NotDeployed,
Staged,
Finalized,
}

fn transactional_test(target_commit: &str) -> Result<()> {
sh_execute!("rpm-ostree cleanup -pr")?;
let status = rpmostree::query_status()?;
let firstdeploy = &status.deployments[0];
let orig_commit = &firstdeploy.checksum;
let elapsed = time_cycle()?;
let iterations = 50u32;
println!(
"Using upgrade duration={:#?} with iterations={}",
elapsed, iterations
);
let elapsed_div = elapsed / iterations;
let mut ok_staged = 0;
let mut ok_finalized = 0;
let mut errs_not_deployed = 0;
let mut errs_staged = 0;
let mut errs_finalized = 0;
let mut rng = rand::thread_rng();
for i in 0..iterations {
println!("iteration={}", i);
let r = thread::spawn(run_cycle);

thread::sleep(rng.gen_range(0, iterations) * elapsed_div);
let _ = sh_execute!(
"systemctl kill -s KILL rpm-ostreed
systemctl kill -s KILL ostree-finalize-staged"
);

let res = r.join().expect("join");
// The systemctl stop above will exit successfully even if
// the process actually died from SIGKILL
let res = if let Ok(_) = res.as_ref() {
let finalize_res = process::Command::new("systemctl")
.args(&["is-failed", "ostree-finalize-staged.service"])
.output()?;
if std::str::from_utf8(&finalize_res.stdout)? == "failed\n" {
Err(anyhow::anyhow!("ostree-finalize-staged.service failed"))
} else {
res
}
} else {
res
};

// This effectively re-validates consistency; TODO
// add more validation periodically like e.g
// `ostree admin fsck`.
let status = rpmostree::query_status()?;
let firstdeploy = &status.deployments[0];
let deployresult = if firstdeploy.checksum == target_commit {
if let Some(true) = firstdeploy.staged {
DeployResult::Staged
} else {
DeployResult::Finalized
}
} else if &firstdeploy.checksum == orig_commit {
DeployResult::NotDeployed
} else {
anyhow::bail!("Unexpected target commit: {}", firstdeploy.checksum);
};
match (res, deployresult) {
(Ok(_), DeployResult::NotDeployed) => {
anyhow::bail!("Got successful result but not deployed!")
}
(Ok(_), DeployResult::Staged) => ok_staged += 1,
(Ok(_), DeployResult::Finalized) => ok_finalized += 1,
(Err(_), DeployResult::NotDeployed) => errs_not_deployed += 1,
(Err(_), DeployResult::Staged) => errs_staged += 1,
(Err(_), DeployResult::Finalized) => errs_finalized += 1,
};
}
println!(
"iterations={} staged={} finalized={} errs=(undeployed={}, staged={}, finalized={})",
iterations, ok_staged, ok_finalized, errs_not_deployed, errs_staged, errs_finalized
);
Ok(())
}

#[itest(destructive = true)]
fn transactionality() -> Result<()> {
testinit()?;
let cancellable = Some(gio::Cancellable::new());
let sysroot = ostree::Sysroot::new_default();
sysroot.load(cancellable.as_ref())?;
assert!(sysroot.is_booted());
let opts = Default::default();
// We need this static across reboots
let srvrepo = Path::new("/var/tmp/ostree-test-srv");
if !srvrepo.exists() {
let tmpdir = tempfile::tempdir_in("/var/tmp")?;
generate_srv_repo(&sysroot, &tmpdir)?;
fs::rename(tmpdir.into_path(), srvrepo)?;
}
let srvrepo_obj = ostree::Repo::new(&gio::File::new_for_path(srvrepo));
srvrepo_obj.open(cancellable.as_ref())?;
let target_commit = srvrepo_obj.resolve_rev(TESTREF, false)?;
let target_commit: String = target_commit.into();
with_webserver_in(&srvrepo, &opts, move |addr| {
let cancellable = Some(gio::Cancellable::new());
let sysroot = ostree::Sysroot::new_default();
sysroot.load(cancellable.as_ref())?;
let repo = sysroot.repo().unwrap();
let status = rpmostree::query_status()?;

let url = format!("http://{}", addr);
sh_execute!(
"ostree remote delete --if-exists testrepo
ostree remote add --set=gpg-verify=false testrepo {url}",
url = url
)?;

transactional_test(&target_commit)?;

Ok(())
})?;
Ok(())
}

#[itest(destructive = true)]
fn other() -> Result<()> {
testinit()?;
Ok(())
}
Loading

0 comments on commit d818375

Please sign in to comment.