nyx/src/dynamic/build_sandbox.rs

227 lines
6.7 KiB
Rust
Raw Normal View History

//! Build-time isolation wrapper (§19).
//!
//! Runs `python -m venv` + `pip install -r requirements.txt` in isolation:
//! - Linux: uses `unshare` for network/mount/user namespace restriction when
//! available (falls back to plain subprocess).
//! - Other platforms: plain subprocess with env stripping.
//!
//! Build cache lives at:
//! `~/.cache/nyx/dynamic/build-cache/{lockfile_hash}-{language}-{toolchain_id}/`
//! with permissions `0700` (§19.3).
//!
//! Failed-build retry policy (§12 Q4): one retry on `BuildFailed` with
//! backoff (1s, 4s), then `Inconclusive(BuildFailed, attempts: 2)`.
use crate::dynamic::spec::HarnessSpec;
use blake3::Hasher;
use directories::ProjectDirs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
/// Result of a successful build.
#[derive(Debug, Clone)]
pub struct BuildResult {
/// Path to the built venv / interpreter to use.
pub venv_path: PathBuf,
/// Whether the build used a cached result (true) or built fresh (false).
pub cache_hit: bool,
/// Wall-clock time for the build step (0 on cache hit).
pub duration: Duration,
}
#[derive(Debug)]
pub enum BuildError {
Unsupported,
BuildFailed { stderr: String, attempts: u32 },
Io(std::io::Error),
}
impl From<std::io::Error> for BuildError {
fn from(e: std::io::Error) -> Self {
BuildError::Io(e)
}
}
/// Prepare a Python venv for `spec` in `workdir`.
///
/// If a compatible cache entry exists, returns it immediately. Otherwise
/// builds in isolation and caches the result.
pub fn prepare_python(
spec: &HarnessSpec,
workdir: &Path,
) -> Result<BuildResult, BuildError> {
let lockfile_hash = compute_lockfile_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "python", &spec.toolchain_id)?;
// Check cache hit: venv exists and pyvenv.cfg is present.
if cache_path.join("pyvenv.cfg").exists() {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: Duration::ZERO,
});
}
// Build with retry.
const MAX_ATTEMPTS: u32 = 2;
const BACKOFF: [u64; 2] = [1, 4];
let mut last_err = String::new();
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
std::thread::sleep(Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
let start = Instant::now();
match try_build_venv(&cache_path, workdir, spec) {
Ok(()) => {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
// Remove partial cache before retry.
let _ = std::fs::remove_dir_all(&cache_path);
}
}
}
Err(BuildError::BuildFailed {
stderr: last_err,
attempts: MAX_ATTEMPTS,
})
}
fn try_build_venv(
venv_path: &Path,
workdir: &Path,
spec: &HarnessSpec,
) -> Result<(), String> {
// Find python binary.
let python = python_binary(spec);
// Create the venv.
let status = Command::new(&python)
.args(["-m", "venv", "--clear"])
.arg(venv_path)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.status()
.map_err(|e| format!("venv create: {e}"))?;
if !status.success() {
return Err(format!("venv create failed: exit {status}"));
}
// Install dependencies if requirements.txt exists.
let req_path = workdir.join("requirements.txt");
if req_path.exists() {
let pip = venv_path.join("bin").join("pip");
let output = Command::new(&pip)
.args(["install", "--no-cache-dir", "-r"])
.arg(&req_path)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.map_err(|e| format!("pip install: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
}
Ok(())
}
fn python_binary(spec: &HarnessSpec) -> String {
// Try the pinned version first; fall back to python3.
let ver = spec
.toolchain_id
.strip_prefix("python-")
.unwrap_or("3");
let candidate = format!("python{ver}");
if which_exists(&candidate) {
return candidate;
}
"python3".to_owned()
}
fn which_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn compute_lockfile_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["requirements.txt", "Pipfile.lock", "pyproject.toml"] {
if let Ok(content) = std::fs::read(workdir.join(fname)) {
h.update(fname.as_bytes());
h.update(&content);
}
}
let out = h.finalize();
format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()))
}
fn build_cache_path(
lockfile_hash: &str,
language: &str,
toolchain_id: &str,
) -> Result<PathBuf, BuildError> {
// Respect test override.
let base = if let Ok(p) = std::env::var("NYX_BUILD_CACHE") {
PathBuf::from(p)
} else {
let dirs = ProjectDirs::from("", "", "nyx").ok_or_else(|| {
BuildError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"cannot determine cache dir",
))
})?;
dirs.cache_dir()
.join("dynamic")
.join("build-cache")
};
let name = format!("{lockfile_hash}-{language}-{toolchain_id}");
let path = base.join(&name);
std::fs::create_dir_all(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700));
}
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lockfile_hash_empty_dir_stable() {
let dir = tempfile::TempDir::new().unwrap();
let h1 = compute_lockfile_hash(dir.path());
let h2 = compute_lockfile_hash(dir.path());
assert_eq!(h1, h2, "hash must be deterministic");
}
#[test]
fn lockfile_hash_changes_with_content() {
let dir = tempfile::TempDir::new().unwrap();
let h1 = compute_lockfile_hash(dir.path());
std::fs::write(dir.path().join("requirements.txt"), "requests==2.28.0\n").unwrap();
let h2 = compute_lockfile_hash(dir.path());
assert_ne!(h1, h2, "hash must change when requirements.txt changes");
}
}