526 lines
17 KiB
Rust
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;
|
|
}
|