Files
sausage/host/tests/it/extensions.rs
T

273 lines
10 KiB
Rust

//! Commit snapshots and last-good recovery with world git and a faux model.
use sausage::{
exec::{Command, Exec, Options, Stream},
world::World,
};
use std::{path::Path, sync::Arc};
const BUNDLE: &str = include_str!("../../js/extensions-test-bundle.js");
struct Author(Arc<dyn Exec>);
#[async_trait::async_trait]
impl Exec for Author {
async fn exec(
&self,
command: Command,
mut options: Options,
output: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send),
) -> anyhow::Result<i32> {
for (key, value) in [
("GIT_AUTHOR_NAME", "Test"),
("GIT_AUTHOR_EMAIL", "test@example.com"),
("GIT_COMMITTER_NAME", "Test"),
("GIT_COMMITTER_EMAIL", "test@example.com"),
] {
options.env.push((key.into(), value.into()));
}
let label = format!("{command:?}");
let budget = options.timeout;
let started = std::time::Instant::now();
let result = self.0.exec(command, options, output).await;
eprintln!(
"Extension {label}: deadline {budget:?}, elapsed {:?}, result {result:?}",
started.elapsed()
);
result
}
}
#[tokio::test(flavor = "current_thread")]
async fn commits_and_restart() -> anyhow::Result<()> {
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
if !userland.join("cwd-init/init.o").is_file() {
crate::common::cwd_init();
}
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--release",
"-p",
"git",
"-p",
"sausage-exec-tools",
"-p",
"sausage-archive-tools",
"--manifest-path"
])
.arg(userland.join("../Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
let disk = dir.path().join("world");
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
disk.join("bin/git.wasm"),
)?;
for name in ["extensions-revert", "env", "tar"] {
std::fs::copy(
target.join(format!("wasm32-wasip2/release/{name}.wasm")),
disk.join(format!("bin/{name}.wasm")),
)?;
}
let repo = disk.join("src/sausage");
std::fs::create_dir_all(&repo)?;
assert!(
std::process::Command::new("git")
.args(["init", "-b", "main"])
.arg(&repo)
.status()?
.success()
);
assert!(
std::process::Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--no-gpg-sign",
"--allow-empty",
"-m",
"empty"
])
.current_dir(&repo)
.status()?
.success()
);
let exec: Arc<dyn Exec> = Arc::new(Author(sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?));
sausage::harness::run_module(world, exec, BUNDLE).await?;
let good = std::fs::read_to_string(repo.join("good.txt"))?;
let world = World::open(dir.path())?;
let exec: Arc<dyn Exec> = Arc::new(Author(sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?));
sausage::harness::run_module(
world,
exec,
&format!("globalThis.extensionRestart=true;globalThis.extensionGood={good:?};\n{BUNDLE}"),
)
.await?;
Ok(())
}
/// Model loader commands without delays, and require cancellation instead of a wall-clock deadline.
struct LoaderCommand {
root: Arc<dyn sausage_vfs::Layer>,
failures: usize,
attempts: std::sync::atomic::AtomicUsize,
timeout: bool,
}
#[async_trait::async_trait]
impl Exec for LoaderCommand {
async fn exec(
&self,
command: Command,
options: Options,
output: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send),
) -> anyhow::Result<i32> {
let Command::Argv(args) = command else {
anyhow::bail!("unexpected shell")
};
assert!(
options.timeout.is_none(),
"Loader work must not have a wall-clock deadline"
);
if args[0] == "tar" {
return Ok(0);
}
match args[1].as_str() {
"rev-parse" => output(Stream::Stdout, b"new\n"),
"ls-tree" => output(Stream::Stdout, b"extensions\n"),
"archive" => {
let attempt = self
.attempts
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if attempt < self.failures && self.timeout {
anyhow::bail!("command timed out");
}
if attempt < self.failures {
anyhow::bail!("checkout failed");
}
if attempt > 0 {
let status = self
.root
.open("/proc/extensions", Default::default())
.await?;
let status = String::from_utf8(status.read(0, 65536).await?)?;
assert!(
status.contains("command timed out"),
"Retry must publish its error: {status}"
);
}
let archive = args[3].strip_prefix("--output=").unwrap();
let file = self
.root
.open(
archive,
sausage_vfs::Open {
write: true,
create: true,
..Default::default()
},
)
.await?;
file.write(0, vec![]).await?;
let snapshot = format!("{}.tmp", archive.strip_suffix(".tar").unwrap());
for path in [
format!("{snapshot}/extensions"),
format!("{snapshot}/extensions/demo"),
] {
self.root.mkdir(&path).await?;
}
let file = self
.root
.open(
&format!("{snapshot}/extensions/demo/server.ts"),
sausage_vfs::Open {
write: true,
create: true,
..Default::default()
},
)
.await?;
file.write(0, b"export default {name:'demo'};".to_vec())
.await?;
}
_ => anyhow::bail!("unexpected command {args:?}"),
}
Ok(0)
}
}
async fn loader_command(failures: usize, timeout: bool) -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
std::fs::create_dir_all(dir.path().join("world/src/sausage/.git"))?;
let exec = Arc::new(LoaderCommand {
root: world.root.clone(),
failures,
timeout,
attempts: std::sync::atomic::AtomicUsize::new(0),
});
let (library, _) = include_str!("../../js/bundle.js")
.split_once("// main.ts")
.unwrap();
let expected = if failures > 1 || !timeout && failures > 0 {
"old"
} else {
"new"
};
let source = format!(
r#"{library}
const testRegistry = createRegistry();
const testStorage = await openStorage(globalThis.storagePath);
const testHarness = await Harness.open(testStorage, {{models:createModels(),registry:testRegistry}}, BACKGROUND_CONTEXT);
await testHarness.root(BACKGROUND_CONTEXT);
await testHarness.commit(async tx => Object.assign(await tx.doc(ExtensionState), {{good:'old',current:'old',error:null}}), BACKGROUND_CONTEXT);
for (const path of ['/run/sausage','/run/sausage/extensions','/run/sausage/extensions/old','/run/sausage/extensions/old/extensions','/run/sausage/extensions/old/extensions/demo']) await fs2.mkdir(path);
await fs2.write('/run/sausage/extensions/old/extensions/demo/server.ts', "export default {{name:'demo'}};");
const testLoader = await loadExtensions(testHarness,testRegistry,testStorage,createModels(),BACKGROUND_CONTEXT);
const testState = testLoader.state();
if(testState.good !== '{expected}') throw new Error(JSON.stringify(testState));
if((testState.error !== null) !== ({expected:?} === 'old')) throw new Error(JSON.stringify(testState));
if(testState.error && !testState.error.includes('git archive')) throw new Error(testState.error);
await testLoader.close();
await testHarness.close(BACKGROUND_CONTEXT);
"#
);
sausage::harness::run_module(world, exec.clone(), &source).await?;
assert_eq!(
exec.attempts.load(std::sync::atomic::Ordering::SeqCst),
if timeout && failures > 0 { 2 } else { 1 }
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn slow_loader_command() -> anyhow::Result<()> {
loader_command(0, true).await
}
#[tokio::test(flavor = "current_thread")]
async fn loader_timeout_retries_once() -> anyhow::Result<()> {
loader_command(1, true).await
}
#[tokio::test(flavor = "current_thread")]
async fn loader_timeout_keeps_last_good() -> anyhow::Result<()> {
loader_command(2, true).await
}
#[tokio::test(flavor = "current_thread")]
async fn loader_real_failure_is_not_retried() -> anyhow::Result<()> {
loader_command(1, false).await
}