mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-24 20:28:06 +02:00
refactor(dynamic): enhance Ruby harness with framework-specific route replay logic (Sinatra, Rails, Hanami), extend Gemfile staging, and update tests/fixtures
This commit is contained in:
parent
41c7b73575
commit
aaf49acefb
20 changed files with 773 additions and 218 deletions
|
|
@ -361,6 +361,150 @@ fn build_cache_path(
|
|||
Ok(path)
|
||||
}
|
||||
|
||||
// ── Ruby build sandbox ───────────────────────────────────────────────────────
|
||||
|
||||
/// Prepare Ruby dependencies for `spec` in `workdir`.
|
||||
///
|
||||
/// Runs `bundle check` first so hosts that already have the declared gems do
|
||||
/// not need network access. When the check misses, runs `bundle install` into
|
||||
/// `vendor/bundle` and caches both that tree and Bundler's local config.
|
||||
pub fn prepare_ruby(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
|
||||
if !workdir.join("Gemfile").exists() {
|
||||
return Ok(BuildResult {
|
||||
venv_path: workdir.to_path_buf(),
|
||||
cache_hit: false,
|
||||
duration: Duration::ZERO,
|
||||
});
|
||||
}
|
||||
|
||||
let lockfile_hash = compute_ruby_lockfile_hash(workdir);
|
||||
let cache_path = build_cache_path(&lockfile_hash, "ruby", &spec.toolchain_id).ok();
|
||||
|
||||
if let Some(cache_path) = &cache_path
|
||||
&& cache_path.join(".ruby_cache_done").exists()
|
||||
{
|
||||
restore_cached_ruby_bundle(cache_path, workdir);
|
||||
return Ok(BuildResult {
|
||||
venv_path: cache_path.clone(),
|
||||
cache_hit: true,
|
||||
duration: Duration::ZERO,
|
||||
});
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
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]));
|
||||
}
|
||||
match try_bundle_install(workdir) {
|
||||
Ok(()) => {
|
||||
if let Some(cache_path) = &cache_path {
|
||||
persist_ruby_bundle(workdir, cache_path);
|
||||
let _ = std::fs::write(cache_path.join(".ruby_cache_done"), b"done");
|
||||
}
|
||||
return Ok(BuildResult {
|
||||
venv_path: cache_path.unwrap_or_else(|| workdir.to_path_buf()),
|
||||
cache_hit: false,
|
||||
duration: start.elapsed(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(BuildError::BuildFailed {
|
||||
stderr: last_err,
|
||||
attempts: MAX_ATTEMPTS,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_bundle_install(workdir: &Path) -> Result<(), String> {
|
||||
let bundle = std::env::var("NYX_BUNDLE_BIN").unwrap_or_else(|_| "bundle".to_owned());
|
||||
if bundle_check(&bundle, workdir)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = Command::new(&bundle)
|
||||
.args(["config", "set", "--local", "path", "vendor/bundle"])
|
||||
.current_dir(workdir)
|
||||
.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!("bundle config: {e}"))?;
|
||||
if !config.status.success() {
|
||||
return Err(String::from_utf8_lossy(&config.stderr).into_owned());
|
||||
}
|
||||
|
||||
let output = Command::new(&bundle)
|
||||
.args(["install", "--jobs", "4", "--retry", "2"])
|
||||
.current_dir(workdir)
|
||||
.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!("bundle install: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundle_check(bundle: &str, workdir: &Path) -> Result<bool, String> {
|
||||
let output = Command::new(bundle)
|
||||
.arg("check")
|
||||
.current_dir(workdir)
|
||||
.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!("bundle check: {e}"))?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
fn restore_cached_ruby_bundle(cache_path: &Path, workdir: &Path) {
|
||||
let cached_vendor = cache_path.join("vendor").join("bundle");
|
||||
if cached_vendor.exists() && !workdir.join("vendor").join("bundle").exists() {
|
||||
let _ = copy_dir_all(&cached_vendor, &workdir.join("vendor").join("bundle"));
|
||||
}
|
||||
let cached_bundle_config = cache_path.join(".bundle");
|
||||
if cached_bundle_config.exists() && !workdir.join(".bundle").exists() {
|
||||
let _ = copy_dir_all(&cached_bundle_config, &workdir.join(".bundle"));
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_ruby_bundle(workdir: &Path, cache_path: &Path) {
|
||||
let vendor = workdir.join("vendor").join("bundle");
|
||||
if vendor.exists() {
|
||||
let _ = copy_dir_all(&vendor, &cache_path.join("vendor").join("bundle"));
|
||||
}
|
||||
let bundle_config = workdir.join(".bundle");
|
||||
if bundle_config.exists() {
|
||||
let _ = copy_dir_all(&bundle_config, &cache_path.join(".bundle"));
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_ruby_lockfile_hash(workdir: &Path) -> String {
|
||||
let mut h = Hasher::new();
|
||||
for fname in &["Gemfile", "Gemfile.lock"] {
|
||||
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())
|
||||
)
|
||||
}
|
||||
|
||||
// ── Node.js build sandbox ─────────────────────────────────────────────────────
|
||||
|
||||
/// Prepare a Node.js project for `spec` in `workdir`.
|
||||
|
|
@ -646,35 +790,37 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, B
|
|||
// from the same sources targeted at `--release 21`.
|
||||
let target_release = java_target_release(&spec.toolchain_id);
|
||||
let source_hash = compute_java_source_hash(workdir, target_release);
|
||||
let cache_path = build_cache_path(&source_hash, "java", &spec.toolchain_id)?;
|
||||
let cache_path = build_cache_path(&source_hash, "java", &spec.toolchain_id).ok();
|
||||
|
||||
let cached_classes = collect_class_files(&cache_path);
|
||||
if let Some(cache_path) = &cache_path {
|
||||
let cached_classes = collect_class_files(cache_path);
|
||||
|
||||
// Cache hit: at least the harness class is compiled. Restore every
|
||||
// cached `.class` to workdir so the classpath (which points to
|
||||
// workdir, not cache_path) can find them when a different finding
|
||||
// hits the same compiled artefact via a fresh spec_hash.
|
||||
if cache_path.join("NyxHarness.class").exists() {
|
||||
for cls in &cached_classes {
|
||||
let src = cache_path.join(cls);
|
||||
let dst = workdir.join(cls);
|
||||
if src.exists() && !dst.exists() {
|
||||
let _ = std::fs::copy(&src, &dst);
|
||||
// Cache hit: at least the harness class is compiled. Restore every
|
||||
// cached `.class` to workdir so the classpath (which points to
|
||||
// workdir, not cache_path) can find them when a different finding
|
||||
// hits the same compiled artefact via a fresh spec_hash.
|
||||
if cache_path.join("NyxHarness.class").exists() {
|
||||
for cls in &cached_classes {
|
||||
let src = cache_path.join(cls);
|
||||
let dst = workdir.join(cls);
|
||||
if src.exists() && !dst.exists() {
|
||||
let _ = std::fs::copy(&src, &dst);
|
||||
}
|
||||
}
|
||||
// Restore cached Maven-resolved jars when the harness shipped a
|
||||
// `pom.xml`; the harness command embeds `-cp .:lib/*` so the
|
||||
// runtime classpath needs these jars staged in the workdir.
|
||||
let cached_lib = cache_path.join("lib");
|
||||
let workdir_lib = workdir.join("lib");
|
||||
if cached_lib.exists() && !workdir_lib.exists() {
|
||||
let _ = copy_dir_all(&cached_lib, &workdir_lib);
|
||||
}
|
||||
return Ok(BuildResult {
|
||||
venv_path: cache_path.clone(),
|
||||
cache_hit: true,
|
||||
duration: std::time::Duration::ZERO,
|
||||
});
|
||||
}
|
||||
// Restore cached Maven-resolved jars when the harness shipped a
|
||||
// `pom.xml`; the harness command embeds `-cp .:lib/*` so the
|
||||
// runtime classpath needs these jars staged in the workdir.
|
||||
let cached_lib = cache_path.join("lib");
|
||||
let workdir_lib = workdir.join("lib");
|
||||
if cached_lib.exists() && !workdir_lib.exists() {
|
||||
let _ = copy_dir_all(&cached_lib, &workdir_lib);
|
||||
}
|
||||
return Ok(BuildResult {
|
||||
venv_path: cache_path,
|
||||
cache_hit: true,
|
||||
duration: std::time::Duration::ZERO,
|
||||
});
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
|
@ -688,10 +834,12 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, B
|
|||
BACKOFF[attempt as usize - 1],
|
||||
));
|
||||
}
|
||||
match try_compile_java(workdir, &cache_path, target_release) {
|
||||
let compile_cache = cache_path.as_deref().unwrap_or(workdir);
|
||||
match try_compile_java(workdir, compile_cache, target_release) {
|
||||
Ok(()) => {
|
||||
let build_root = cache_path.clone().unwrap_or_else(|| workdir.to_path_buf());
|
||||
return Ok(BuildResult {
|
||||
venv_path: cache_path,
|
||||
venv_path: build_root,
|
||||
cache_hit: false,
|
||||
duration: start.elapsed(),
|
||||
});
|
||||
|
|
@ -700,7 +848,9 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, B
|
|||
last_err = e;
|
||||
// Best-effort clean-up: drop every cached `.class` so the
|
||||
// next attempt re-compiles from source.
|
||||
if let Ok(entries) = std::fs::read_dir(&cache_path) {
|
||||
if let Some(cache_path) = &cache_path
|
||||
&& let Ok(entries) = std::fs::read_dir(cache_path)
|
||||
{
|
||||
for entry in entries.flatten() {
|
||||
if entry
|
||||
.path()
|
||||
|
|
@ -797,25 +947,27 @@ fn try_compile_java(
|
|||
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
|
||||
}
|
||||
|
||||
// Copy class files to cache. `javac -d workdir` writes nested
|
||||
// package directories under workdir; preserve the relative layout
|
||||
// when caching so the restore path can recreate them.
|
||||
for cls in collect_class_files(workdir) {
|
||||
let src = workdir.join(&cls);
|
||||
let dst = cache_path.join(&cls);
|
||||
if let Some(parent) = dst.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
if cache_path != workdir {
|
||||
// Copy class files to cache. `javac -d workdir` writes nested
|
||||
// package directories under workdir; preserve the relative layout
|
||||
// when caching so the restore path can recreate them.
|
||||
for cls in collect_class_files(workdir) {
|
||||
let src = workdir.join(&cls);
|
||||
let dst = cache_path.join(&cls);
|
||||
if let Some(parent) = dst.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if src.exists() {
|
||||
let _ = std::fs::copy(&src, &dst);
|
||||
}
|
||||
}
|
||||
if src.exists() {
|
||||
let _ = std::fs::copy(&src, &dst);
|
||||
}
|
||||
}
|
||||
// Persist Maven-resolved jars alongside the class cache so cache-hit
|
||||
// restores can rebuild the `lib/` classpath without re-running mvn.
|
||||
if lib_on_cp {
|
||||
let lib_src = workdir.join("lib");
|
||||
if lib_src.exists() {
|
||||
let _ = copy_dir_all(&lib_src, &cache_path.join("lib"));
|
||||
// Persist Maven-resolved jars alongside the class cache so cache-hit
|
||||
// restores can rebuild the `lib/` classpath without re-running mvn.
|
||||
if lib_on_cp {
|
||||
let lib_src = workdir.join("lib");
|
||||
if lib_src.exists() {
|
||||
let _ = copy_dir_all(&lib_src, &cache_path.join("lib"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -1333,10 +1485,7 @@ pub struct ChainStepBuildResult {
|
|||
/// build its own per-step command vector.
|
||||
///
|
||||
/// `profile` is consulted only on [`Lang::C`] (drives `-static`); the
|
||||
/// other per-language preparers ignore it. [`Lang::Ruby`] returns
|
||||
/// [`BuildError::Unsupported`] because there is no `prepare_ruby` —
|
||||
/// the runner's match arm falls through to a `_ => {}` no-op for Ruby
|
||||
/// today, so the reverifier mirrors that contract.
|
||||
/// other per-language preparers ignore it.
|
||||
pub fn dispatch_prepare(
|
||||
spec: &HarnessSpec,
|
||||
workdir: &Path,
|
||||
|
|
@ -1350,9 +1499,9 @@ pub fn dispatch_prepare(
|
|||
Lang::Go => prepare_go(spec, workdir)?,
|
||||
Lang::Java => prepare_java(spec, workdir)?,
|
||||
Lang::Php => prepare_php(spec, workdir)?,
|
||||
Lang::Ruby => prepare_ruby(spec, workdir)?,
|
||||
Lang::C => prepare_c(spec, workdir, profile)?,
|
||||
Lang::Cpp => prepare_cpp(spec, workdir)?,
|
||||
Lang::Ruby => return Err(BuildError::Unsupported),
|
||||
};
|
||||
Ok(ChainStepBuildResult {
|
||||
lang,
|
||||
|
|
@ -1949,18 +2098,21 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_prepare_ruby_returns_unsupported() {
|
||||
// Ruby has no prepare_ruby — the runner falls through to a `_`
|
||||
// no-op for it. The dispatcher mirrors that contract so the
|
||||
// composite-chain reverifier can distinguish "build skipped"
|
||||
// from "build failed" instead of silently producing a result.
|
||||
fn dispatch_prepare_ruby_routes_to_bundler_no_gemfile_path() {
|
||||
// Ruby now has the same dependency-prep leg as the other
|
||||
// interpreted framework harnesses. With no Gemfile present,
|
||||
// prepare_ruby takes the cheap path and records an empty cache
|
||||
// entry without invoking Bundler.
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
let _cache = BuildCacheGuard::isolated();
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let spec = mk_spec(Lang::Ruby, "ruby-unsupported");
|
||||
let result = dispatch_prepare(&spec, dir.path(), ProcessHardeningProfile::Standard);
|
||||
assert!(
|
||||
matches!(result, Err(BuildError::Unsupported)),
|
||||
"Ruby must route to BuildError::Unsupported; got {result:?}",
|
||||
);
|
||||
let spec = mk_spec(Lang::Ruby, "ruby-no-gemfile");
|
||||
let result = dispatch_prepare(&spec, dir.path(), ProcessHardeningProfile::Standard)
|
||||
.expect("Ruby dispatch must succeed on a workdir with no Gemfile");
|
||||
assert_eq!(result.lang, Lang::Ruby);
|
||||
assert!(!result.cache_hit);
|
||||
assert_eq!(result.duration, Duration::ZERO);
|
||||
assert!(result.build_root.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue