Merge branch 'v3' into v3-t90
This commit is contained in:
+31
-2
@@ -44,10 +44,39 @@ async fn main() -> anyhow::Result<()> {
|
||||
.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Default tools unavailable. Set SAUSAGE_USERLAND or pass --userland."
|
||||
"Copy tools failed: default tools unavailable. Set SAUSAGE_USERLAND or pass --userland."
|
||||
)
|
||||
})?;
|
||||
sausage::world::World::init(&world, &bundle).await?;
|
||||
use std::io::{IsTerminal, Write};
|
||||
let terminal = std::io::stderr().is_terminal();
|
||||
let mut progress_line = false;
|
||||
let initialized = sausage::world::World::init_with_progress(&world, &bundle, |line| {
|
||||
if terminal && line.starts_with("Precompile tools ") {
|
||||
eprint!("\r\x1b[2K{line}");
|
||||
let _ = std::io::stderr().flush();
|
||||
progress_line = true;
|
||||
} else {
|
||||
if progress_line {
|
||||
eprintln!();
|
||||
progress_line = false;
|
||||
}
|
||||
eprintln!("{line}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if progress_line {
|
||||
eprintln!();
|
||||
}
|
||||
initialized?;
|
||||
eprintln!("Next steps:");
|
||||
eprintln!(
|
||||
" Edit {} to configure providers and API keys.",
|
||||
world.join("world/etc/sausage/config.yaml").display()
|
||||
);
|
||||
eprintln!(
|
||||
" sausage run '{}'",
|
||||
world.to_string_lossy().replace('\'', "'\\''")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Command::Run { world } => {
|
||||
|
||||
@@ -130,6 +130,38 @@ impl Compiler {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Finish precompilation during init and report each bundled tool, excluding executable aliases.
|
||||
pub async fn precompile_with_progress(
|
||||
root: &dyn sausage_vfs::Layer,
|
||||
mut progress: impl FnMut(usize, usize) + Send,
|
||||
) -> anyhow::Result<()> {
|
||||
use anyhow::Context;
|
||||
let compiler = shared()?;
|
||||
let mut paths = Vec::new();
|
||||
for folder in ["/bin", "/usr/local/bin"] {
|
||||
for entry in root.list(folder).await? {
|
||||
if !entry.directory && entry.name.ends_with(".wasm") {
|
||||
paths.push(format!("{folder}/{}", entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.sort_by_key(|path| priority(path.rsplit('/').next().unwrap()));
|
||||
progress(0, paths.len());
|
||||
for (index, path) in paths.iter().enumerate() {
|
||||
let file = root
|
||||
.open(path, Default::default())
|
||||
.await
|
||||
.with_context(|| format!("Precompile {path} failed"))?;
|
||||
let bytes = file.read(0, file.stat().await?.size as usize).await?;
|
||||
compiler
|
||||
.get(bytes)
|
||||
.await
|
||||
.with_context(|| format!("Precompile {path} failed"))?;
|
||||
progress(index + 1, paths.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn priority(name: &str) -> usize {
|
||||
[
|
||||
"sh", "git", "rg", "grep", "ls", "cat", "sed", "find", "cargo",
|
||||
@@ -196,6 +228,33 @@ mod tests {
|
||||
use crate::exec::{Command, Exec, Options};
|
||||
use std::{future::Future, sync::atomic::Ordering};
|
||||
|
||||
#[tokio::test]
|
||||
async fn init_progress_counts_tools_not_aliases() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = crate::world::World::open(dir.path())?;
|
||||
let bin = dir.path().join("world/bin");
|
||||
std::fs::create_dir_all(&bin)?;
|
||||
let mut progress = Vec::new();
|
||||
precompile_with_progress(world.root.as_ref(), |done, total| {
|
||||
progress.push((done, total))
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(progress, [(0, 0)]);
|
||||
let bytes =
|
||||
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?;
|
||||
std::fs::write(bin.join("one.wasm"), &bytes)?;
|
||||
std::fs::write(dir.path().join("world/usr/local/bin/two.wasm"), &bytes)?;
|
||||
std::fs::write(bin.join("README"), "Not a program")?;
|
||||
std::os::unix::fs::symlink("one.wasm", bin.join("one"))?;
|
||||
progress.clear();
|
||||
precompile_with_progress(world.root.as_ref(), |done, total| {
|
||||
progress.push((done, total))
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(progress, [(0, 2), (1, 2), (2, 2)]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_tools_precede_other_programs() {
|
||||
let mut names = [
|
||||
|
||||
@@ -55,7 +55,7 @@ pub mod net;
|
||||
pub fn default_script() -> Arc<dyn crate::cred::script::Script> {
|
||||
Arc::new(crate::cred::quickjs::QuickJs::default())
|
||||
}
|
||||
pub use cache::configure as configure_cache;
|
||||
pub use cache::{configure as configure_cache, precompile_with_progress};
|
||||
mod services;
|
||||
mod watcher;
|
||||
pub use watcher::Watcher;
|
||||
|
||||
+56
-11
@@ -3,6 +3,7 @@
|
||||
//! Layout of a world directory:
|
||||
//! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it.
|
||||
//! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it.
|
||||
use anyhow::Context;
|
||||
use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process};
|
||||
use std::{
|
||||
os::unix::fs::PermissionsExt,
|
||||
@@ -45,17 +46,34 @@ fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
|
||||
impl World {
|
||||
/// Initialize a fresh world and commit its plain bundled source on main.
|
||||
pub async fn init(dir: &Path, bundle: &Path) -> anyhow::Result<Self> {
|
||||
Self::init_with_progress(dir, bundle, |_| {}).await
|
||||
}
|
||||
|
||||
/// Initialize a world and report each step without changing normal startup output.
|
||||
pub async fn init_with_progress(
|
||||
dir: &Path,
|
||||
bundle: &Path,
|
||||
mut progress: impl FnMut(&str) + Send,
|
||||
) -> anyhow::Result<Self> {
|
||||
anyhow::ensure!(
|
||||
!dir.join("world").exists(),
|
||||
"World already exists: {}",
|
||||
"Create world folder failed: World already exists: {}",
|
||||
dir.display()
|
||||
);
|
||||
let world = Self::open_with_userland(dir, Some(bundle))?;
|
||||
let world = Self::open_with_progress(dir, Some(bundle), &mut progress)
|
||||
.context("Create world folder and seed files failed")?;
|
||||
crate::process::precompile_with_progress(world.root.as_ref(), |done, total| {
|
||||
progress(&format!("Precompile tools {done}/{total}"));
|
||||
})
|
||||
.await
|
||||
.context("Precompile tools failed")?;
|
||||
progress("Create the first commit in /src/sausage");
|
||||
let exec = crate::process::exec(Self {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
})
|
||||
.context("Create source repository failed")?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
vec!["add", "."],
|
||||
@@ -76,7 +94,8 @@ impl World {
|
||||
},
|
||||
&mut |_, bytes| output.extend_from_slice(bytes),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.context("Create source repository failed")?;
|
||||
anyhow::ensure!(
|
||||
code == 0,
|
||||
"Cannot initialize source repository: {}",
|
||||
@@ -97,22 +116,48 @@ impl World {
|
||||
/// `/dev` exposes generated null, zero, and random byte devices in every world.
|
||||
/// Missing `/etc/gitconfig` defaults to Sausage <sausage@localhost>. Local repository config overrides it.
|
||||
pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result<Self> {
|
||||
Self::open_with_progress(dir, userland, &mut |_| {})
|
||||
}
|
||||
|
||||
fn open_with_progress(
|
||||
dir: &Path,
|
||||
userland: Option<&Path>,
|
||||
progress: &mut dyn FnMut(&str),
|
||||
) -> anyhow::Result<Self> {
|
||||
progress(&format!("Create world folder: {}", dir.display()));
|
||||
let root_dir = dir.join("world");
|
||||
let host_dir = dir.join("host");
|
||||
let fresh = !root_dir.exists();
|
||||
std::fs::create_dir_all(root_dir.join("usr/local/bin"))?;
|
||||
std::fs::create_dir_all(root_dir.join("usr/local/bin"))
|
||||
.context("Create world folder failed")?;
|
||||
for path in ["tmp", "run", "var/tmp"] {
|
||||
std::fs::create_dir_all(root_dir.join(path))?;
|
||||
std::fs::create_dir_all(root_dir.join(path)).context("Create world folder failed")?;
|
||||
}
|
||||
if fresh && let Some(bundle) = userland {
|
||||
for folder in ["bin", "src"] {
|
||||
copy_tree(&bundle.join(folder), &root_dir.join(folder))?;
|
||||
}
|
||||
let tools = std::fs::read_dir(bundle.join("bin"))
|
||||
.context("Copy tools failed")?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("Copy tools failed")?
|
||||
.iter()
|
||||
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wasm"))
|
||||
.count();
|
||||
progress(&format!("Copy tools: {tools}"));
|
||||
copy_tree(&bundle.join("bin"), &root_dir.join("bin")).context("Copy tools failed")?;
|
||||
progress("Create /src/sausage");
|
||||
copy_tree(&bundle.join("src"), &root_dir.join("src"))
|
||||
.context("Create /src/sausage failed")?;
|
||||
progress("Seed /etc and /srv/www");
|
||||
if bundle.join("srv").is_dir() {
|
||||
copy_tree(&bundle.join("srv"), &root_dir.join("srv"))?;
|
||||
copy_tree(&bundle.join("srv"), &root_dir.join("srv"))
|
||||
.context("Seed /srv/www failed")?;
|
||||
}
|
||||
if bundle.join("etc").is_dir() {
|
||||
copy_tree(&bundle.join("etc"), &root_dir.join("etc"))?;
|
||||
progress(&format!(
|
||||
"Write config: {}",
|
||||
root_dir.join("etc/sausage/config.yaml").display()
|
||||
));
|
||||
copy_tree(&bundle.join("etc"), &root_dir.join("etc"))
|
||||
.context("Seed /etc and write config failed")?;
|
||||
}
|
||||
// Keep independent .wasm files and expose conventional executable names.
|
||||
for entry in std::fs::read_dir(root_dir.join("bin"))? {
|
||||
|
||||
@@ -60,7 +60,8 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> {
|
||||
"etc/sausage/config.yaml",
|
||||
include_str!("../../../config/config.yaml"),
|
||||
)?;
|
||||
let destination = dir.path().join("new-world");
|
||||
write(&bundle, "srv/www/index.html", "World page")?;
|
||||
let destination = dir.path().join("new world");
|
||||
let initialized = std::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("init")
|
||||
.arg(&destination)
|
||||
@@ -75,6 +76,36 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> {
|
||||
"{}",
|
||||
String::from_utf8_lossy(&initialized.stderr)
|
||||
);
|
||||
assert!(initialized.stdout.is_empty());
|
||||
let stderr = String::from_utf8(initialized.stderr)?;
|
||||
let lines: Vec<_> = stderr.lines().collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
format!("Create world folder: {}", destination.display()),
|
||||
"Copy tools: 1".into(),
|
||||
"Create /src/sausage".into(),
|
||||
"Seed /etc and /srv/www".into(),
|
||||
format!(
|
||||
"Write config: {}",
|
||||
destination.join("world/etc/sausage/config.yaml").display()
|
||||
),
|
||||
"Precompile tools 0/1".into(),
|
||||
"Precompile tools 1/1".into(),
|
||||
"Create the first commit in /src/sausage".into(),
|
||||
"Next steps:".into(),
|
||||
format!(
|
||||
" Edit {} to configure providers and API keys.",
|
||||
destination.join("world/etc/sausage/config.yaml").display()
|
||||
),
|
||||
format!(" sausage run '{}'", destination.display()),
|
||||
]
|
||||
);
|
||||
assert!(!stderr.contains(['\r', '\x1b']));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(destination.join("world/srv/www/index.html"))?,
|
||||
"World page"
|
||||
);
|
||||
let world = World::open(&destination)?;
|
||||
assert!(destination.join("world/bin/git").exists());
|
||||
let config = world
|
||||
@@ -112,6 +143,52 @@ async fn init_seeds_config_tools_and_one_source_commit() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_errors_name_the_failed_step() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let bundle = dir.path().join("bundle");
|
||||
for (name, expected) in [
|
||||
("missing", "Copy tools failed"),
|
||||
("invalid", "Precompile tools failed"),
|
||||
] {
|
||||
if name == "invalid" {
|
||||
write(&bundle, "bin/invalid.wasm", "not a component")?;
|
||||
write(&bundle, "src/sausage/README.md", "Source")?;
|
||||
}
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("init")
|
||||
.arg(dir.path().join(name))
|
||||
.arg("--userland")
|
||||
.arg(&bundle)
|
||||
.output()?;
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr)?;
|
||||
assert!(stderr.contains(expected), "{stderr}");
|
||||
assert!(!stderr.contains("Next steps:"), "{stderr}");
|
||||
if name == "invalid" {
|
||||
assert!(stderr.contains("/bin/invalid.wasm"), "{stderr}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_does_not_print_init_progress() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("run")
|
||||
.arg(dir.path())
|
||||
.output()?;
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
let stderr = String::from_utf8(output.stderr)?;
|
||||
assert!(stderr.starts_with("Error: No world at "), "{stderr}");
|
||||
assert!(!stderr.contains("Create world folder"));
|
||||
assert!(!stderr.contains("Next steps:"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn core_extensions() -> anyhow::Result<()> {
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user