Merge v3-t96: --no-builder-sandbox host switch (builder without bubblewrap, for the Nix sandbox) (T96)
This commit is contained in:
@@ -10,6 +10,7 @@ Keep the complete world folder between starts.
|
||||
|
||||
Run host commands from a checkout of the `v3` branch on Citadel.
|
||||
Use Nix with flakes. The package supplies the native Rust builder and its tools.
|
||||
Use `sausage run --no-builder-sandbox WORLD` only when the host must run the builder without namespace isolation.
|
||||
|
||||
The package entry point is:
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ use crate::cred::Cred;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
/// Preserve the console lifecycle with one API for guests and host pages.
|
||||
pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> {
|
||||
pub async fn run_with_userland(
|
||||
path: &Path,
|
||||
userland: Option<&Path>,
|
||||
options: crate::process::RuntimeOptions,
|
||||
) -> anyhow::Result<()> {
|
||||
let world = crate::world::World::open_with_userland(path, userland)?;
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let api = Arc::new(Api::open(
|
||||
@@ -13,7 +17,7 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::
|
||||
"localhost",
|
||||
"http://localhost:8080",
|
||||
)?);
|
||||
let runtime = crate::process::Runtime::new_with_hooks(
|
||||
let runtime = crate::process::Runtime::new_with_hooks_and_options(
|
||||
crate::world::World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
@@ -22,6 +26,7 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::
|
||||
cred.clone(),
|
||||
crate::process::default_script(),
|
||||
Some(api.clone()),
|
||||
options,
|
||||
)?;
|
||||
let (sender, receiver) = crate::harness::input::channel();
|
||||
api.attach_harness(sender.clone());
|
||||
|
||||
@@ -29,9 +29,14 @@ It also owns a registry and kache cache.
|
||||
These directories live under `<dir>/host/builds/`.
|
||||
|
||||
Cargo fetches dependencies with network access.
|
||||
The command then runs offline inside bubblewrap namespaces.
|
||||
By default, the command then runs offline inside bubblewrap namespaces.
|
||||
The host-only `sausage run --no-builder-sandbox WORLD` switch runs Cargo directly instead.
|
||||
Tests use `RuntimeOptions.no_builder_sandbox` and select it with `SAUSAGE_TEST_NO_BUILDER_SANDBOX=1`.
|
||||
The switch is not a world config setting.
|
||||
Direct mode uses no user, mount, network, or PID namespaces.
|
||||
It retains the source copy, working directory, private registry, kache settings, timeout, output, and artifact publication.
|
||||
|
||||
The source copy and target directory permit writes.
|
||||
In the default sandboxed mode, the source copy and target directory permit writes.
|
||||
The private kache cache also permits writes.
|
||||
The registry and Nix store remain read-only.
|
||||
Host home directories and the host network are absent.
|
||||
|
||||
+64
-44
@@ -47,15 +47,23 @@ pub struct Builder {
|
||||
root: Arc<dyn Layer>,
|
||||
directory: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
no_sandbox: bool,
|
||||
}
|
||||
impl Builder {
|
||||
pub fn new(root: Arc<dyn Layer>, host: &Path) -> Result<Self> {
|
||||
Self::new_with_options(root, host, false)
|
||||
}
|
||||
pub fn new_with_options(root: Arc<dyn Layer>, host: &Path, no_sandbox: bool) -> Result<Self> {
|
||||
Ok(Self {
|
||||
root,
|
||||
directory: std::fs::canonicalize(host)?.join("builds"),
|
||||
lock: Mutex::new(()),
|
||||
no_sandbox,
|
||||
})
|
||||
}
|
||||
fn command(&self) -> Command {
|
||||
Command::new(if self.no_sandbox { "cargo" } else { "bwrap" })
|
||||
}
|
||||
pub async fn execute(&self, request: Request, tx: &mpsc::Sender<Event>) -> Result<()> {
|
||||
let _guard = self.lock.lock().await;
|
||||
let action = request
|
||||
@@ -221,60 +229,72 @@ impl Builder {
|
||||
if action == "run" && run_path.exists() {
|
||||
std::fs::remove_file(&run_path)?;
|
||||
}
|
||||
let mut cmd = Command::new("bwrap");
|
||||
cmd.args([
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--unshare-net",
|
||||
"--die-with-parent",
|
||||
"--ro-bind",
|
||||
"/nix/store",
|
||||
"/nix/store",
|
||||
])
|
||||
.arg("--bind")
|
||||
.arg(&temporary)
|
||||
.arg("/tmp")
|
||||
.arg("--ro-bind")
|
||||
.arg(executable("sh")?)
|
||||
.arg("/bin/sh");
|
||||
for path in [&source, &target_mount, &cache] {
|
||||
cmd.arg("--bind").arg(path).arg(path);
|
||||
let mut cmd = self.command();
|
||||
if !self.no_sandbox {
|
||||
cmd.args([
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--unshare-net",
|
||||
"--die-with-parent",
|
||||
"--ro-bind",
|
||||
"/nix/store",
|
||||
"/nix/store",
|
||||
])
|
||||
.arg("--bind")
|
||||
.arg(&temporary)
|
||||
.arg("/tmp")
|
||||
.arg("--ro-bind")
|
||||
.arg(executable("sh")?)
|
||||
.arg("/bin/sh");
|
||||
for path in [&source, &target_mount, &cache] {
|
||||
cmd.arg("--bind").arg(path).arg(path);
|
||||
}
|
||||
for path in [®istry, &tools] {
|
||||
cmd.arg("--ro-bind").arg(path).arg(path);
|
||||
}
|
||||
cmd.args([
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--remount-ro",
|
||||
"/",
|
||||
"--chdir",
|
||||
])
|
||||
.arg(&cwd)
|
||||
.arg("--clearenv");
|
||||
} else {
|
||||
cmd.current_dir(&cwd);
|
||||
}
|
||||
for path in [®istry, &tools] {
|
||||
cmd.arg("--ro-bind").arg(path).arg(path);
|
||||
}
|
||||
cmd.args([
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--remount-ro",
|
||||
"/",
|
||||
"--chdir",
|
||||
])
|
||||
.arg(&cwd)
|
||||
.args(["--clearenv", "--setenv", "PATH"])
|
||||
.arg(std::env::var("PATH")?)
|
||||
.args(["--setenv", "HOME", "/nonexistent", "--setenv", "CARGO_HOME"])
|
||||
.arg(®istry)
|
||||
.args(["--setenv", "CARGO_TARGET_DIR"])
|
||||
.arg(&target)
|
||||
.args(["--setenv", "KACHE_CACHE_DIR"])
|
||||
.arg(&cache)
|
||||
.args(["--setenv", "SAUSAGE_CWD_INIT"])
|
||||
.arg(tools.join("init.o"))
|
||||
.args(["--setenv", "SAUSAGE_RUN_PATH"])
|
||||
.arg(&run_path);
|
||||
let mut environment = vec![
|
||||
("PATH", std::env::var_os("PATH").context("PATH missing")?),
|
||||
("HOME", "/nonexistent".into()),
|
||||
("CARGO_HOME", registry.clone().into_os_string()),
|
||||
("CARGO_TARGET_DIR", target.clone().into_os_string()),
|
||||
("KACHE_CACHE_DIR", cache.clone().into_os_string()),
|
||||
("SAUSAGE_CWD_INIT", tools.join("init.o").into_os_string()),
|
||||
("SAUSAGE_RUN_PATH", run_path.clone().into_os_string()),
|
||||
];
|
||||
for name in [
|
||||
"CC_wasm32_wasip2",
|
||||
"AR_wasm32_wasip2",
|
||||
"CFLAGS_wasm32_wasip2",
|
||||
] {
|
||||
if let Ok(value) = std::env::var(name) {
|
||||
environment.push((name, value.into()));
|
||||
}
|
||||
}
|
||||
for (name, value) in environment {
|
||||
if self.no_sandbox {
|
||||
cmd.env(name, value);
|
||||
} else {
|
||||
cmd.args(["--setenv", name]).arg(value);
|
||||
}
|
||||
}
|
||||
cmd.args(["cargo", "--offline", "--config"])
|
||||
if !self.no_sandbox {
|
||||
cmd.arg("cargo");
|
||||
}
|
||||
cmd.args(["--offline", "--config"])
|
||||
.arg(format!("build.rustc-wrapper={:?}", kache))
|
||||
.arg("--config")
|
||||
.arg(format!(
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
//! Focused tests for source discovery, argument forwarding, eviction, and process cancellation.
|
||||
use super::*;
|
||||
#[test]
|
||||
fn native_command_defaults_to_bwrap_and_can_bypass_it() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let world = crate::world::World::open(temp.path())?;
|
||||
assert!(!crate::process::RuntimeOptions::default().no_builder_sandbox);
|
||||
let default = Builder::new(world.root.clone(), &world.host_dir)?;
|
||||
assert_eq!(default.command().as_std().get_program(), "bwrap");
|
||||
let direct = Builder::new_with_options(world.root, &world.host_dir, true)?;
|
||||
assert_eq!(direct.command().as_std().get_program(), "cargo");
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn source_links_survive_round_trip() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
|
||||
+16
-3
@@ -21,7 +21,12 @@ enum Command {
|
||||
userland: Option<PathBuf>,
|
||||
},
|
||||
/// Start a world: the harness, its services and a console.
|
||||
Run { world: PathBuf },
|
||||
Run {
|
||||
world: PathBuf,
|
||||
/// Run the native Rust builder without namespace isolation.
|
||||
#[arg(long)]
|
||||
no_builder_sandbox: bool,
|
||||
},
|
||||
/// Run a shell in a world. With `-c`, run one command and exit with its status.
|
||||
Sh {
|
||||
world: PathBuf,
|
||||
@@ -79,14 +84,22 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Command::Run { world } => {
|
||||
Command::Run {
|
||||
world,
|
||||
no_builder_sandbox,
|
||||
} => {
|
||||
anyhow::ensure!(
|
||||
world.join("world").is_dir(),
|
||||
"No world at {}. Run sausage init {} first.",
|
||||
world.display(),
|
||||
world.display()
|
||||
);
|
||||
sausage::api::run_with_userland(&world, None).await
|
||||
sausage::api::run_with_userland(
|
||||
&world,
|
||||
None,
|
||||
sausage::process::RuntimeOptions { no_builder_sandbox },
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Sh {
|
||||
world,
|
||||
|
||||
@@ -289,7 +289,7 @@ mod tests {
|
||||
let _ = started_tx.send(());
|
||||
let _ = finish_rx.recv();
|
||||
}));
|
||||
let runtime = super::super::Runtime::with_compiler(world, compiler)?;
|
||||
let runtime = super::super::Runtime::with_compiler(world, compiler, Default::default())?;
|
||||
started_rx.await?;
|
||||
let command_runtime = runtime.clone();
|
||||
let mut command = tokio::spawn(async move {
|
||||
@@ -353,7 +353,8 @@ mod tests {
|
||||
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?,
|
||||
)?;
|
||||
compiler.get(parent).await?;
|
||||
let runtime = super::super::Runtime::with_compiler(world, compiler.clone())?;
|
||||
let runtime =
|
||||
super::super::Runtime::with_compiler(world, compiler.clone(), Default::default())?;
|
||||
let deadline = Deadline::new(Duration::ZERO);
|
||||
let request = |name: &str| Request {
|
||||
program: name.into(),
|
||||
@@ -471,7 +472,9 @@ mod tests {
|
||||
finish_rx.recv().unwrap();
|
||||
}));
|
||||
// Startup returns while the compiler remains blocked behind the test gate.
|
||||
let runtime = super::super::Runtime::with_compiler(world, compiler.clone()).unwrap();
|
||||
let runtime =
|
||||
super::super::Runtime::with_compiler(world, compiler.clone(), Default::default())
|
||||
.unwrap();
|
||||
started_rx.await.unwrap();
|
||||
let background = compiler.clone();
|
||||
let precompile = tokio::spawn(async move { background.get(bytes).await });
|
||||
|
||||
+38
-9
@@ -154,20 +154,36 @@ struct Started {
|
||||
output: Option<DuplexStream>,
|
||||
error: Option<DuplexStream>,
|
||||
}
|
||||
/// Host-only switches for native builder execution. Defaults retain namespace isolation.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct RuntimeOptions {
|
||||
pub no_builder_sandbox: bool,
|
||||
}
|
||||
impl Runtime {
|
||||
pub fn open(path: &Path) -> anyhow::Result<Arc<Self>> {
|
||||
Self::new(World::open(path)?)
|
||||
}
|
||||
pub fn new(world: World) -> anyhow::Result<Arc<Self>> {
|
||||
Self::with_compiler(world, cache::shared()?)
|
||||
Self::new_with_options(world, RuntimeOptions::default())
|
||||
}
|
||||
pub fn new_with_options(world: World, options: RuntimeOptions) -> anyhow::Result<Arc<Self>> {
|
||||
Self::with_compiler(world, cache::shared()?, options)
|
||||
}
|
||||
/// Open an independent engine using an explicit shared disk cache.
|
||||
pub fn new_with_cache(world: World, directory: &Path) -> anyhow::Result<Arc<Self>> {
|
||||
Self::with_compiler(world, cache::Compiler::new(directory)?)
|
||||
Self::with_compiler(
|
||||
world,
|
||||
cache::Compiler::new(directory)?,
|
||||
RuntimeOptions::default(),
|
||||
)
|
||||
}
|
||||
fn with_compiler(world: World, compiler: Arc<cache::Compiler>) -> anyhow::Result<Arc<Self>> {
|
||||
fn with_compiler(
|
||||
world: World,
|
||||
compiler: Arc<cache::Compiler>,
|
||||
options: RuntimeOptions,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
|
||||
Self::with_hooks(world, compiler, cred, default_script(), None)
|
||||
Self::with_hooks(world, compiler, cred, default_script(), None, options)
|
||||
}
|
||||
/// Share credentials and the host API router with the host's other subsystems.
|
||||
pub fn new_with_hooks(
|
||||
@@ -176,7 +192,16 @@ impl Runtime {
|
||||
script: Arc<dyn crate::cred::script::Script>,
|
||||
router: Option<Router>,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
Self::with_hooks(world, cache::shared()?, cred, script, router)
|
||||
Self::new_with_hooks_and_options(world, cred, script, router, RuntimeOptions::default())
|
||||
}
|
||||
pub fn new_with_hooks_and_options(
|
||||
world: World,
|
||||
cred: Arc<crate::cred::Cred>,
|
||||
script: Arc<dyn crate::cred::script::Script>,
|
||||
router: Option<Router>,
|
||||
options: RuntimeOptions,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
Self::with_hooks(world, cache::shared()?, cred, script, router, options)
|
||||
}
|
||||
fn with_hooks(
|
||||
world: World,
|
||||
@@ -184,6 +209,7 @@ impl Runtime {
|
||||
cred: Arc<crate::cred::Cred>,
|
||||
script: Arc<dyn crate::cred::script::Script>,
|
||||
router: Option<http::Router>,
|
||||
options: RuntimeOptions,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let router = match router {
|
||||
Some(api) => api,
|
||||
@@ -195,10 +221,13 @@ impl Runtime {
|
||||
)?),
|
||||
};
|
||||
router.attach_journal(world.root.journal());
|
||||
let _ = router.builder.set(Arc::new(crate::builder::Builder::new(
|
||||
world.root.clone(),
|
||||
&world.host_dir,
|
||||
)?));
|
||||
let _ = router
|
||||
.builder
|
||||
.set(Arc::new(crate::builder::Builder::new_with_options(
|
||||
world.root.clone(),
|
||||
&world.host_dir,
|
||||
options.no_builder_sandbox,
|
||||
)?));
|
||||
let network = Arc::new(net::NetPolicy::open(&world.host_dir)?);
|
||||
let engine = compiler.engine.clone();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
//! Assert the native build script's namespace boundaries.
|
||||
fn main() {
|
||||
assert!(!std::path::Path::new("/home/surma/.ssh").exists());
|
||||
assert!(
|
||||
!std::path::Path::new(&std::env::var("HOME").unwrap())
|
||||
.join(".ssh")
|
||||
.exists()
|
||||
);
|
||||
assert!(std::fs::write("/outside-probe", b"no").is_err());
|
||||
assert!(
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&"1.1.1.1:443".parse().unwrap(),
|
||||
std::time::Duration::from_millis(100)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
if std::env::var("SAUSAGE_TEST_NO_BUILDER_SANDBOX").as_deref() == Ok("1") {
|
||||
println!("cargo:warning=namespace assertions skipped: builder sandbox disabled");
|
||||
} else {
|
||||
assert!(!std::path::Path::new("/home/surma/.ssh").exists());
|
||||
assert!(
|
||||
!std::path::Path::new(&std::env::var("HOME").unwrap())
|
||||
.join(".ssh")
|
||||
.exists()
|
||||
);
|
||||
assert!(std::fs::write("/outside-probe", b"no").is_err());
|
||||
assert!(
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&"1.1.1.1:443".parse().unwrap(),
|
||||
std::time::Duration::from_millis(100)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
println!("cargo:warning=namespace assertions passed");
|
||||
}
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
let object = out.join("add.o");
|
||||
assert!(
|
||||
@@ -35,5 +40,4 @@ fn main() {
|
||||
);
|
||||
println!("cargo:rustc-link-search=native={}", out.display());
|
||||
println!("cargo:rustc-link-lib=static=add");
|
||||
println!("cargo:warning=namespace assertions passed");
|
||||
}
|
||||
|
||||
@@ -16,7 +16,14 @@ fn programs() -> PathBuf {
|
||||
crate::common::cwd_init();
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "world-cargo"])
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"-p",
|
||||
"world-cargo"
|
||||
])
|
||||
.current_dir(root.join("userland"))
|
||||
.status()
|
||||
.unwrap()
|
||||
@@ -63,7 +70,7 @@ async fn cargo_scaffold_and_human_output() -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(&bin)?;
|
||||
std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?;
|
||||
std::fs::copy(binaries.join("brush.wasm"), bin.join("sh.wasm"))?;
|
||||
let runtime = Runtime::new(world)?;
|
||||
let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?;
|
||||
for (script, cwd, expected, success) in [
|
||||
(
|
||||
"cargo new hello && cd hello && cargo run",
|
||||
@@ -201,10 +208,15 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> {
|
||||
] {
|
||||
std::fs::write(source.join(path), bytes)?;
|
||||
}
|
||||
let runtime = Runtime::new(world)?;
|
||||
let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?;
|
||||
let (code, out) = cargo(&runtime, &["build"]).await?;
|
||||
assert_eq!(code, 0, "{out}");
|
||||
assert!(out.contains("namespace assertions passed"), "{out}");
|
||||
if crate::common::runtime_options().no_builder_sandbox {
|
||||
println!("SKIP namespace isolation assertions: SAUSAGE_TEST_NO_BUILDER_SANDBOX=1");
|
||||
assert!(out.contains("namespace assertions skipped"), "{out}");
|
||||
} else {
|
||||
assert!(out.contains("namespace assertions passed"), "{out}");
|
||||
}
|
||||
assert!(out.contains("(/project)"), "{out}");
|
||||
assert!(!out.contains("/host/builds/"), "{out}");
|
||||
assert_eq!(
|
||||
@@ -349,7 +361,11 @@ async fn timeout_and_cancellation() -> anyhow::Result<()> {
|
||||
source.join("build.rs"),
|
||||
"fn main() { std::process::Command::new(\"sh\").args([\"-c\", \"echo $$ > child.pid; exec sleep 1000\"]).status().unwrap(); }",
|
||||
)?;
|
||||
let builder = Arc::new(Builder::new(world.root.clone(), &world.host_dir)?);
|
||||
let builder = Arc::new(Builder::new_with_options(
|
||||
world.root.clone(),
|
||||
&world.host_dir,
|
||||
crate::common::runtime_options().no_builder_sandbox,
|
||||
)?);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
|
||||
let task = tokio::spawn(async move {
|
||||
builder
|
||||
@@ -375,7 +391,7 @@ async fn timeout_and_cancellation() -> anyhow::Result<()> {
|
||||
let bin = temp.path().join("world/bin");
|
||||
std::fs::create_dir_all(&bin)?;
|
||||
std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?;
|
||||
let runtime = Runtime::new(world)?;
|
||||
let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?;
|
||||
let error = runtime
|
||||
.exec(
|
||||
Command::Argv(vec!["/bin/cargo.wasm".into(), "build".into()]),
|
||||
|
||||
@@ -8,6 +8,12 @@ use std::{
|
||||
};
|
||||
pub static BUILD: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub fn runtime_options() -> sausage::process::RuntimeOptions {
|
||||
sausage::process::RuntimeOptions {
|
||||
no_builder_sandbox: std::env::var("SAUSAGE_TEST_NO_BUILDER_SANDBOX").as_deref() == Ok("1"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the real shared prompt in every roster fixture, with an ephemeral approval port.
|
||||
pub fn write_config(dir: &Path, standing: &str, roster: serde_json::Value) -> anyhow::Result<()> {
|
||||
let mut config: serde_json::Value =
|
||||
|
||||
Reference in New Issue
Block a user