mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-12 21:02:11 +02:00
[pitboss] phase 02: M2 — Python end-to-end excellence with all hardening baked in
This commit is contained in:
parent
894f587b60
commit
0bf39047b9
50 changed files with 4167 additions and 170 deletions
226
src/dynamic/build_sandbox.rs
Normal file
226
src/dynamic/build_sandbox.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue