//! 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 = 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, Arc) { 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, 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, 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, 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, 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::(&bytes) .is_ok_and(|s| s["state"] == expected) { break; } tokio::task::yield_now().await; } }) .await .unwrap(); } fn lines(path: &Path) -> Vec { 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; }