Files
sausage/host/tests/init.rs
T

526 lines
17 KiB
Rust

//! Init acceptance tests use real WASI programs, a persistent guest cache, and temporary worlds.
use sausage::{
exec::{Command, Exec, Options, Stream},
process::{Runtime, supervise},
world::World,
};
use sausage_vfs::{Layer, Open};
use std::{
path::{Path, PathBuf},
sync::{Arc, OnceLock},
time::Duration,
};
static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn guests() -> &'static Path {
static BUILT: OnceLock<PathBuf> = OnceLock::new();
BUILT.get_or_init(|| {
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("init-guests");
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/Cargo.toml");
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"-p",
"sausage-init",
"--manifest-path"
])
.arg(manifest)
.env("CARGO_TARGET_DIR", &target)
.status()
.unwrap()
.success()
);
target.join("wasm32-wasip2/release")
})
}
fn world() -> (tempfile::TempDir, Arc<Runtime>, Arc<dyn Layer>) {
let temp = tempfile::tempdir().unwrap();
let bin = temp.path().join("world/bin");
std::fs::create_dir_all(&bin).unwrap();
for (source, name) in [
("init", "init"),
("systemctl", "systemctl"),
("journalctl", "journalctl"),
("systemd-run", "systemd-run"),
("init-probe", "probe"),
] {
std::fs::copy(
guests().join(format!("{source}.wasm")),
bin.join(format!("{name}.wasm")),
)
.unwrap();
}
std::fs::create_dir_all(
temp.path()
.join("world/etc/systemd/system/default.target.wants"),
)
.unwrap();
let cache = Path::new(env!("CARGO_TARGET_TMPDIR")).join("init-components");
std::fs::create_dir_all(&cache).unwrap();
std::fs::create_dir_all(temp.path().join("host")).unwrap();
std::os::unix::fs::symlink(cache, temp.path().join("host/compile-cache")).unwrap();
let world = World::open(temp.path()).unwrap();
let root = world.root.clone();
(temp, Runtime::new(world).unwrap(), root)
}
async fn write(root: &Arc<dyn Layer>, path: &str, text: &[u8]) {
let file = root
.open(
path,
Open {
create: true,
write: true,
truncate: true,
..Default::default()
},
)
.await
.unwrap();
file.write(0, text.to_vec()).await.unwrap();
}
async fn unit(root: &Arc<dyn Layer>, name: &str, body: &str) {
write(
root,
&format!("/etc/systemd/system/default.target.wants/{name}.service"),
b"",
)
.await;
write(
root,
&format!("/etc/systemd/system/{name}.service"),
body.as_bytes(),
)
.await;
}
async fn run(runtime: &Arc<Runtime>, argv: &[&str]) -> (i32, String) {
let mut out = Vec::new();
let code = runtime
.exec(
Command::Argv(argv.iter().map(|a| (*a).into()).collect()),
Options {
timeout: Some(Duration::from_secs(30)),
cwd: "/".into(),
..Default::default()
},
&mut |stream, bytes| {
if stream == Stream::Stdout {
out.extend_from_slice(bytes);
} else {
eprint!("{}", String::from_utf8_lossy(bytes));
}
},
)
.await
.unwrap();
(code, String::from_utf8_lossy(&out).into_owned())
}
async fn wait(mut check: impl FnMut() -> bool) {
tokio::time::timeout(Duration::from_secs(30), async {
while !check() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
async fn state(root: &Arc<dyn Layer>, unit: &str, expected: &str) {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
if let Ok(file) = root
.open(
&format!("/run/systemd/state/{unit}.service"),
Default::default(),
)
.await
&& let Ok(bytes) = file.read(0, 65536).await
&& serde_json::from_slice::<serde_json::Value>(&bytes)
.is_ok_and(|s| s["state"] == expected)
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
fn lines(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect()
}
#[tokio::test]
async fn unrelated_bursts_and_watched_overflow_only_reload_changed_units() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world");
wait(|| path.join("var/log/journal").exists()).await;
for name in ["changed", "unchanged"] {
write(
&root,
&format!("/run/systemd/transient/{name}.service"),
format!("[Service]\nExecStart=probe server /{name}.log\n").as_bytes(),
)
.await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "start", name])
.await
.0,
0
);
wait(|| lines(&path.join(format!("{name}.log"))) == ["start"]).await;
}
tokio::task::unconstrained(async {
for _ in 0..4000 {
write(&root, "/run/unrelated", b"noise").await;
}
})
.await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "start", "unchanged"])
.await
.0,
0
);
assert_eq!(lines(&path.join("changed.log")), ["start"]);
assert_eq!(lines(&path.join("unchanged.log")), ["start"]);
// This single-thread executor cannot schedule init during ready tmpfs writes.
// The watched queue therefore overflows before init can consume the edits.
tokio::task::unconstrained(async {
write(
&root,
"/run/systemd/transient/changed.service",
b"[Service]\nExecStart=probe server /changed.log\nEnvironment=REV=2\n",
)
.await;
for _ in 0..1000 {
write(&root, "/run/systemd/transient/noise", b"noise").await;
}
})
.await;
wait(|| lines(&path.join("changed.log")) == ["start", "stop", "start"]).await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "start", "unchanged"])
.await
.0,
0
);
assert_eq!(lines(&path.join("unchanged.log")), ["start"]);
// Bare names also watch the resolved executable, not the literal name.
let mut bytes = std::fs::read(guests().join("init-probe.wasm")).unwrap();
bytes.extend_from_slice(&[0, 2, 1, b'x']);
write(&root, "/bin/replacement.wasm", &bytes).await;
root.rename("/bin/replacement.wasm", "/bin/probe.wasm")
.await
.unwrap();
wait(|| lines(&path.join("unchanged.log")) == ["start", "stop", "start"]).await;
supervisor.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn watcher_and_child_exit_pollable() {
let _serial = SERIAL.lock().await;
let (_temp, runtime, _) = world();
let result = run(&runtime, &["/bin/probe.wasm", "watch"]).await;
assert_eq!(result.0, 0, "{}", result.1);
}
#[tokio::test(flavor = "multi_thread")]
async fn dependencies_restart_limit_and_journal() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
unit(
&root,
"prepare",
"[Service]\nType=oneshot\nExecStart=/bin/probe.wasm oneshot /order.log\n",
)
.await;
unit(&root, "after", "[Unit]\nAfter=prepare.service\nRequires=prepare.service\n[Service]\nExecStart=/bin/probe.wasm after /after.log\n").await;
unit(
&root,
"fail",
"[Service]\nType=oneshot\nExecStart=/bin/probe.wasm exit 2\n",
)
.await;
unit(&root, "blocked", "[Unit]\nAfter=fail.service\nRequires=fail.service\n[Service]\nExecStart=/bin/probe.wasm oneshot /blocked.log\n").await;
unit(&root, "crash", "[Service]\nExecStart=/bin/probe.wasm crash /crash.log\nRestart=on-failure\nRestartSec=0.01\n").await;
unit(&root, "always", "[Service]\nExecStart=/bin/probe.wasm oneshot /always.log\nRestart=always\nRestartSec=0.01\nStartLimitBurst=2\n").await;
unit(
&root,
"success",
"[Service]\nExecStart=/bin/probe.wasm oneshot /success.log\nRestart=on-failure\n",
)
.await;
unit(
&root,
"no",
"[Service]\nExecStart=/bin/probe.wasm crash /no.log\nRestart=no\n",
)
.await;
unit(
&root,
"linger",
"[Service]\nExecStart=/bin/probe.wasm linger /linger.log\nTimeoutStopSec=0.02\n",
)
.await;
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world");
wait(|| {
lines(&path.join("after.log")) == ["start"] && lines(&path.join("crash.log")).len() == 5
})
.await;
state(&root, "always", "failed").await;
assert_eq!(lines(&path.join("always.log")).len(), 2);
assert_eq!(lines(&path.join("success.log")).len(), 1);
assert_eq!(lines(&path.join("no.log")).len(), 1);
state(&root, "crash", "failed").await;
state(&root, "after", "active").await;
assert_eq!(lines(&path.join("order.log")), ["start"]);
assert!(!path.join("blocked.log").exists());
wait(|| {
std::fs::read_to_string(path.join("var/log/journal/after.service.log"))
.unwrap_or_default()
.contains("line 10")
})
.await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "is-active", "after"])
.await
.0,
0
);
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "is-active", "crash"])
.await
.0,
3
);
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "is-failed", "crash"])
.await
.0,
0
);
let (code, output) = run(
&runtime,
&["/bin/journalctl.wasm", "-u", "after", "-n", "5"],
)
.await;
assert_eq!(code, 0);
assert_eq!(output, "line 6\nline 7\nline 8\nline 9\nline 10\n");
state(&root, "linger", "active").await;
assert_eq!(
run(&runtime, &["/bin/systemctl.wasm", "stop", "linger"])
.await
.0,
0
);
state(&root, "linger", "inactive").await;
assert_eq!(lines(&path.join("linger.log")), ["start"]);
supervisor.shutdown().await;
assert_eq!(lines(&path.join("after.log")), ["start", "stop"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn live_unit_changes_and_binary_replacement() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world");
unit(
&root,
"live",
"[Service]\nExecStart=/bin/probe.wasm server /live.log\n",
)
.await;
wait(|| lines(&path.join("live.log")) == ["start"]).await;
write(
&root,
"/etc/systemd/system/live.service",
b"[Service]\nExecStart=/bin/probe.wasm server /live.log\nEnvironment=REV=2\n",
)
.await;
wait(|| lines(&path.join("live.log")) == ["start", "stop", "start"]).await;
let mut bytes = std::fs::read(guests().join("init-probe.wasm")).unwrap();
// Add a valid custom section, so the replacement differs in content.
bytes.extend_from_slice(&[0, 2, 1, b'x']);
write(&root, "/bin/replacement.wasm", &bytes).await;
root.rename("/bin/replacement.wasm", "/bin/probe.wasm")
.await
.unwrap();
wait(|| lines(&path.join("live.log")).len() >= 5).await;
root.remove_file("/etc/systemd/system/live.service")
.await
.unwrap();
wait(|| {
lines(&path.join("live.log"))
.last()
.is_some_and(|l| l == "stop")
})
.await;
supervisor.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn enable_persists_but_transient_units_do_not() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
write(&root, "/etc/systemd/system/persistent.service", b"[Service]\nExecStart=/bin/probe.wasm server /persistent.log\n[Install]\nWantedBy=default.target\n").await;
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world");
wait(|| path.join("var/log/journal").exists()).await;
assert_eq!(
run(
&runtime,
&["/bin/systemctl.wasm", "enable", "--now", "persistent"]
)
.await
.0,
0
);
wait(|| lines(&path.join("persistent.log")) == ["start"]).await;
assert_eq!(
run(
&runtime,
&["/bin/systemctl.wasm", "is-enabled", "persistent"]
)
.await
.0,
0
);
assert_eq!(
run(
&runtime,
&[
"/bin/systemd-run.wasm",
"--unit",
"transient",
"--wait",
"/bin/probe.wasm",
"oneshot",
"/transient.log"
]
)
.await
.0,
0
);
supervisor.shutdown().await;
drop(root);
drop(runtime);
let world = World::open(temp.path()).unwrap();
assert!(
world
.root
.stat("/run/systemd/transient/transient.service", true)
.await
.is_err()
);
let runtime = Runtime::new(world).unwrap();
let supervisor = supervise(runtime.clone());
wait(|| lines(&path.join("persistent.log")) == ["start", "stop", "start"]).await;
assert_eq!(lines(&path.join("transient.log")), ["start"]);
supervisor.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn init_crash_reaps_children_and_restarts_enabled_units() {
let _serial = SERIAL.lock().await;
let (temp, runtime, root) = world();
unit(
&root,
"server",
"[Service]\nExecStart=/bin/probe.wasm server /server.log\n",
)
.await;
let supervisor = supervise(runtime.clone());
let path = temp.path().join("world/server.log");
wait(|| lines(&path) == ["start"]).await;
assert_eq!(run(&runtime, &["/bin/probe.wasm", "kill-init"]).await.0, 0);
wait(|| {
lines(&path)
.iter()
.filter(|line| line.as_str() == "start")
.count()
== 2
})
.await;
let table = root
.open("/proc/processes", Default::default())
.await
.unwrap()
.read(0, 65536)
.await
.unwrap();
let table = String::from_utf8(table).unwrap();
assert_eq!(
table
.lines()
.filter(|l| l.contains("/bin/probe.wasm server"))
.count(),
1
);
supervisor.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn journal_follow_observes_new_lines() {
let _serial = SERIAL.lock().await;
let (_temp, runtime, root) = world();
// Init creates the public journal directory.
let supervisor = supervise(runtime.clone());
tokio::time::timeout(Duration::from_secs(30), async {
while root.stat("/var/log/journal", true).await.is_err() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
write(&root, "/var/log/journal/x.service.log", b"old\n").await;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let executor = runtime.clone();
let task = tokio::spawn(async move {
executor
.exec(
Command::Argv(vec![
"/bin/journalctl.wasm".into(),
"-u".into(),
"x".into(),
"-f".into(),
]),
Options {
cwd: "/".into(),
..Default::default()
},
&mut |_, bytes| {
let _ = tx.send(bytes.to_vec());
},
)
.await
});
let first = tokio::time::timeout(Duration::from_secs(30), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(first, b"old\n");
let file = root
.open(
"/var/log/journal/x.service.log",
Open {
write: true,
..Default::default()
},
)
.await
.unwrap();
file.append(b"new\n".to_vec()).await.unwrap();
let next = tokio::time::timeout(Duration::from_secs(30), rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(next, b"new\n");
task.abort();
let _ = task.await;
supervisor.shutdown().await;
}