From bafdf60144c82d3f8ea4206b4a6e8761e9b9f1e4 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 27 Mar 2026 15:00:37 +0100 Subject: [PATCH 1/3] refactor: extract native.rs and vm.rs from main.rs Move all Linux-only code (sim execution, inspect, run-in) to native.rs and VM dispatch to vm.rs. Run/Prepare/Test commands auto-detect: native on Linux, VM elsewhere. Shared RunArgs struct via clap flatten. VmCommand Test reuses TestArgs. No more scattered #[cfg(target_os)] in main.rs. Co-Authored-By: Claude Opus 4.6 (1M context) --- patchbay-cli/Cargo.toml | 18 +- patchbay-cli/src/init.rs | 7 - patchbay-cli/src/main.rs | 1010 +++++++++--------------------------- patchbay-cli/src/native.rs | 371 +++++++++++++ patchbay-cli/src/vm.rs | 195 +++++++ 5 files changed, 835 insertions(+), 766 deletions(-) delete mode 100644 patchbay-cli/src/init.rs create mode 100644 patchbay-cli/src/native.rs create mode 100644 patchbay-cli/src/vm.rs diff --git a/patchbay-cli/Cargo.toml b/patchbay-cli/Cargo.toml index cbf5ca4..ddf7aaa 100644 --- a/patchbay-cli/Cargo.toml +++ b/patchbay-cli/Cargo.toml @@ -14,16 +14,12 @@ path = "src/main.rs" [dependencies] anyhow = "1" chrono = { version = "0.4", default-features = false, features = ["clock"] } -clap = { version = "4", features = ["derive"] } -patchbay = { workspace = true } -patchbay-runner = { workspace = true } -patchbay-vm = { workspace = true, optional = true } -patchbay-server = { workspace = true, optional = true } -patchbay-utils = { workspace = true } -ctor = "0.6" -nix = { version = "0.30", features = ["signal", "process"] } +clap = { version = "4", features = ["derive", "env"] } flate2 = "1" open = "5" +patchbay-server = { workspace = true, optional = true } +patchbay-utils = { workspace = true } +patchbay-vm = { workspace = true, optional = true } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -32,6 +28,12 @@ tokio = { version = "1", features = ["rt", "macros", "sync", "time", "fs", "proc toml = "1.0" tracing = "0.1" +[target.'cfg(target_os = "linux")'.dependencies] +ctor = "0.6" +nix = { version = "0.30", features = ["signal", "process"] } +patchbay = { workspace = true } +patchbay-runner = { workspace = true } + [dev-dependencies] patchbay = { workspace = true } serde_json = "1" diff --git a/patchbay-cli/src/init.rs b/patchbay-cli/src/init.rs deleted file mode 100644 index a2384b1..0000000 --- a/patchbay-cli/src/init.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! ELF .init_array bootstrap — runs before main() and before tokio creates threads. -#[cfg(target_os = "linux")] -#[ctor::ctor] -fn userns_ctor() { - // SAFETY: single-threaded ELF init context; raw libc only. - unsafe { patchbay::init_userns_for_ctor() } -} diff --git a/patchbay-cli/src/main.rs b/patchbay-cli/src/main.rs index d4b82cf..832d5f8 100644 --- a/patchbay-cli/src/main.rs +++ b/patchbay-cli/src/main.rs @@ -1,31 +1,28 @@ //! Unified CLI entrypoint for patchbay simulations (native and VM). mod compare; -mod init; +#[cfg(target_os = "linux")] +mod native; mod test; #[cfg(feature = "upload")] mod upload; mod util; +#[cfg(feature = "vm")] +mod vm; -#[cfg(target_os = "linux")] -use std::collections::HashMap; -use std::{ - path::{Path, PathBuf}, - process::Command as ProcessCommand, - time::Duration, -}; +use std::path::{Path, PathBuf}; +#[cfg(feature = "serve")] +use std::process::Command as ProcessCommand; +use std::time::Duration; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; -use patchbay::check_caps; -use patchbay_runner::sim; +use serde::Deserialize; + #[cfg(feature = "serve")] use patchbay_server::DEFAULT_UI_BIND; #[cfg(not(feature = "serve"))] const DEFAULT_UI_BIND: &str = "127.0.0.1:7421"; -#[cfg(feature = "vm")] -use patchbay_vm::VmOps; -use serde::{Deserialize, Serialize}; #[derive(Parser)] #[command(name = "patchbay", about = "Run a patchbay simulation")] @@ -39,45 +36,12 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Run one or more sims locally. + /// Run one or more sims (native on Linux, VM elsewhere). Run { - /// One or more sim TOML files or directories containing `*.toml`. - #[arg()] - sims: Vec, - - /// Work directory for logs, binaries, and results. - #[arg(long, default_value = ".patchbay/work")] - work_dir: PathBuf, - - /// Binary override in `::` form. - #[arg(long = "binary")] - binary_overrides: Vec, - - /// Do not build binaries; resolve expected artifacts from target dirs. - #[arg(long, default_value_t = false)] - no_build: bool, - /// Stream live stdout/stderr lines with node prefixes. - #[arg(short = 'v', long, default_value_t = false)] - verbose: bool, - - /// Start embedded UI server and open browser. - #[arg(long, default_value_t = false)] - open: bool, - - /// Bind address for embedded UI server. - #[arg(long, default_value = DEFAULT_UI_BIND)] - bind: String, - - /// Project root directory for resolving binaries and cargo builds. - /// Defaults to the current working directory. - #[arg(long)] - project_root: Option, - - /// Per-sim timeout (e.g. "120s", "5m"). Sims that exceed this are aborted. - #[arg(long)] - timeout: Option, + #[command(flatten)] + args: RunArgs, }, - /// Resolve sims and build all required assets without running simulations. + /// Resolve sims and build all required assets without running. Prepare { /// One or more sim TOML files or directories containing `*.toml`. #[arg()] @@ -141,7 +105,7 @@ enum Command { #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)] cmd: Vec, }, - /// Run tests (delegates to cargo test on native, VM test flow on VM). + /// Run tests (native on Linux, VM elsewhere; --vm forces VM backend). Test { #[command(flatten)] args: test::TestArgs, @@ -177,7 +141,7 @@ enum Command { #[cfg(feature = "vm")] Vm { #[command(subcommand)] - command: VmCommand, + command: vm::VmCommand, /// Which VM backend to use. #[arg(long, default_value = "auto", global = true)] backend: patchbay_vm::Backend, @@ -221,85 +185,6 @@ enum CompareCommand { }, } -/// VM sub-subcommands (mirrors patchbay-vm's standalone CLI). -#[cfg(feature = "vm")] -#[derive(Subcommand)] -enum VmCommand { - /// Boot or reuse VM and ensure mounts. - Up { - #[arg(long)] - recreate: bool, - }, - /// Stop VM and helper processes. - Down, - /// Show VM running status. - Status, - /// Best-effort cleanup of VM helper artifacts/processes. - Cleanup, - /// Execute command in the guest (SSH for QEMU, exec for container). - Ssh { - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - cmd: Vec, - }, - /// Run one or more sims in VM using guest patchbay binary. - Run { - #[arg(required = true)] - sims: Vec, - #[arg(long, default_value = ".patchbay/work")] - work_dir: PathBuf, - #[arg(long = "binary")] - binary_overrides: Vec, - #[arg(short = 'v', long, default_value_t = false)] - verbose: bool, - #[arg(long)] - recreate: bool, - #[arg(long, default_value = "latest")] - patchbay_version: String, - #[arg(long, default_value_t = false)] - open: bool, - #[arg(long, default_value = DEFAULT_UI_BIND)] - bind: String, - }, - /// Serve embedded UI + work directory over HTTP. - Serve { - #[arg(long, default_value = ".patchbay/work")] - work_dir: PathBuf, - /// Serve `/binaries/tests/testdir-current` instead of work_dir. - #[arg(long, default_value_t = false)] - testdir: bool, - #[arg(long, default_value = DEFAULT_UI_BIND)] - bind: String, - #[arg(long, default_value_t = false)] - open: bool, - }, - /// Build and run tests in VM. - Test { - /// Test name filter (passed to test binaries at runtime). - #[arg()] - filter: Option, - #[arg(long, default_value_t = patchbay_vm::default_test_target())] - target: String, - #[arg(short = 'p', long = "package")] - packages: Vec, - #[arg(long = "test")] - tests: Vec, - #[arg(short = 'j', long)] - jobs: Option, - #[arg(short = 'F', long)] - features: Vec, - #[arg(long)] - release: bool, - #[arg(long)] - lib: bool, - #[arg(long)] - no_fail_fast: bool, - #[arg(long)] - recreate: bool, - #[arg(last = true)] - cargo_args: Vec, - }, -} - fn resolve_project_root(opt: Option) -> Result { match opt { Some(p) => Ok(p), @@ -308,7 +193,8 @@ fn resolve_project_root(opt: Option) -> Result { } fn main() -> Result<()> { - patchbay::init_userns()?; + #[cfg(target_os = "linux")] + native::init()?; tokio_main() } @@ -317,76 +203,14 @@ async fn tokio_main() -> Result<()> { patchbay_utils::init_tracing(); let cli = Cli::parse(); match cli.command { - Command::Run { - sims, - work_dir, - binary_overrides, - no_build, - verbose, - open, - bind: _bind, - project_root, - timeout, - } => { - let sim_timeout = timeout - .map(|s| sim::steps::parse_duration(&s)) - .transpose() - .context("invalid --timeout value")?; - if open { - #[cfg(feature = "serve")] - { - let bind_addr = _bind.clone(); - let work = work_dir.clone(); - tokio::spawn(async move { - if let Err(e) = patchbay_server::serve(work, &bind_addr).await { - tracing::error!("server error: {e}"); - } - }); - println!("patchbay: http://{_bind}/"); - let url = format!("http://{_bind}/"); - let _ = std::process::Command::new("xdg-open").arg(&url).spawn(); - } - #[cfg(not(feature = "serve"))] - bail!("--open requires the `serve` feature"); - } - let project_root = resolve_project_root(project_root)?; - let sims = resolve_sim_args(sims, &project_root)?; - let res = sim::run_sims( - sims, - work_dir, - binary_overrides, - verbose, - Some(project_root), - no_build, - sim_timeout, - ) - .await; - if open && res.is_ok() { - println!("run finished; server still running (Ctrl-C to exit)"); - loop { - tokio::time::sleep(Duration::from_secs(60)).await; - } - } - res - } + Command::Run { args } => dispatch_run(args).await, Command::Prepare { sims, work_dir, binary_overrides, no_build, project_root, - } => { - let project_root = resolve_project_root(project_root)?; - let sims = resolve_sim_args(sims, &project_root)?; - sim::prepare_sims( - sims, - work_dir, - binary_overrides, - Some(project_root), - no_build, - ) - .await - } + } => dispatch_prepare(sims, work_dir, binary_overrides, no_build, project_root).await, #[cfg(feature = "serve")] Command::Serve { outdir, @@ -402,130 +226,21 @@ async fn tokio_main() -> Result<()> { println!("patchbay: serving {} at http://{bind}/", dir.display()); if open { let url = format!("http://{bind}/"); - let _ = std::process::Command::new("xdg-open").arg(&url).spawn(); + let _ = ProcessCommand::new("xdg-open").arg(&url).spawn(); } patchbay_server::serve(dir, &bind).await } #[cfg(target_os = "linux")] - Command::Inspect { input, work_dir } => inspect_command(input, work_dir).await, + Command::Inspect { input, work_dir } => native::inspect_command(input, work_dir).await, #[cfg(target_os = "linux")] Command::RunIn { node, inspect, work_dir, cmd, - } => run_in_command(node, inspect, work_dir, cmd), - Command::Test { args, persist, vm } => { - #[cfg(feature = "vm")] - if let Some(vm_backend) = vm { - let backend = match vm_backend.as_str() { - "auto" => patchbay_vm::Backend::Auto.resolve(), - "qemu" => patchbay_vm::Backend::Qemu, - "container" => patchbay_vm::Backend::Container, - other => bail!("unknown VM backend: {other}"), - }; - return test::run_vm(args, backend); - } - #[cfg(not(feature = "vm"))] - if vm.is_some() { - bail!("VM support not compiled (enable the `vm` feature)"); - } - test::run_native(args, cli.verbose, persist) - } - Command::Compare { command } => { - let cwd = std::env::current_dir().context("get cwd")?; - let work_dir = cwd.join(".patchbay/work"); - match command { - CompareCommand::Test { - left_ref, - right_ref, - force_build, - no_ref_build, - args, - } => { - use patchbay_utils::manifest::{self as mf, RunKind}; - - let right_label = right_ref.as_deref().unwrap_or("worktree"); - println!( - "patchbay compare test: {} \u{2194} {}", - left_ref, right_label - ); - - // Helper: resolve results for a ref, using cache or building. - let resolve_ref_results = - |git_ref: &str, label: &str| -> Result> { - let sha = mf::resolve_ref(git_ref) - .with_context(|| format!("could not resolve ref '{git_ref}'"))?; - - // Check cache (unless --force-build). - if !force_build { - if let Some((_dir, manifest)) = - mf::find_run_for_commit(&work_dir, &sha, RunKind::Test) - { - println!("Using cached run for {label} ({sha:.8})"); - return Ok(manifest.tests); - } - } - - // No cache — fail if --no-ref-build. - if no_ref_build { - bail!( - "no cached run for {label} ({sha:.8}); \ - run `patchbay test --persist` on that ref first, \ - or remove --no-ref-build" - ); - } - - // Build in worktree. - println!("Running tests in {label} ..."); - let tree_dir = compare::setup_worktree(git_ref, &cwd)?; - let (results, _output) = - compare::run_tests_in_dir(&tree_dir, &args, cli.verbose)?; - - // Persist the run so future compares can reuse it. - compare::persist_worktree_run(&tree_dir, &results, &sha)?; - - compare::cleanup_worktree(&tree_dir)?; - Ok(results) - }; - - let left_results = resolve_ref_results(&left_ref, &left_ref)?; - - let right_results = if let Some(ref r) = right_ref { - resolve_ref_results(r, r)? - } else { - // Compare against current worktree: always run fresh. - println!("Running tests in worktree ..."); - let (results, _output) = - compare::run_tests_in_dir(&cwd, &args, cli.verbose)?; - results - }; - - // Compare - let result = compare::compare_results(&left_results, &right_results); - compare::print_summary( - &left_ref, - right_label, - &left_results, - &right_results, - &result, - ); - - if result.regressions > 0 { - bail!("{} regressions detected", result.regressions); - } - Ok(()) - } - CompareCommand::Run { - sims: _, - left_ref: _, - right_ref: _, - } => { - // TODO: implement compare run (sim comparison) - bail!("compare run is not yet implemented"); - } - } - } + } => native::run_in_command(node, inspect, work_dir, cmd), + Command::Test { args, persist, vm } => dispatch_test(args, persist, vm, cli.verbose), + Command::Compare { command } => dispatch_compare(command, cli.verbose), Command::Upload { dir, project, @@ -546,118 +261,259 @@ async fn tokio_main() -> Result<()> { } } #[cfg(feature = "vm")] - Command::Vm { command, backend } => dispatch_vm(command, backend).await, + Command::Vm { command, backend } => vm::dispatch_vm(command, backend).await, } } -/// Dispatch VM subcommands to the patchbay-vm library. -#[cfg(feature = "vm")] -async fn dispatch_vm(command: VmCommand, backend: patchbay_vm::Backend) -> Result<()> { - let backend = backend.resolve(); +// ── Run dispatch ──────────────────────────────────────────────────────── - match command { - VmCommand::Up { recreate } => backend.up(recreate), - VmCommand::Down => backend.down(), - VmCommand::Status => backend.status(), - VmCommand::Cleanup => backend.cleanup(), - VmCommand::Ssh { cmd } => backend.exec(cmd), - VmCommand::Run { - sims, - work_dir, - binary_overrides, - verbose, - recreate, - patchbay_version, - open, - bind, - } => { - if open { - let url = format!("http://{bind}"); - println!("patchbay UI: {url}"); - let _ = open::that(&url); - let work = work_dir.clone(); - let bind_clone = bind.clone(); +#[derive(clap::Args)] +struct RunArgs { + /// One or more sim TOML files or directories containing `*.toml`. + #[arg()] + sims: Vec, + /// Work directory for logs, binaries, and results. + #[arg(long, default_value = ".patchbay/work")] + work_dir: PathBuf, + /// Binary override in `::` form. + #[arg(long = "binary")] + binary_overrides: Vec, + /// Do not build binaries; resolve expected artifacts from target dirs. + #[arg(long, default_value_t = false)] + no_build: bool, + /// Stream live stdout/stderr lines with node prefixes. + #[arg(short = 'v', long, default_value_t = false)] + verbose: bool, + /// Start embedded UI server and open browser. + #[arg(long, default_value_t = false)] + open: bool, + /// Bind address for embedded UI server. + #[arg(long, default_value = DEFAULT_UI_BIND)] + bind: String, + /// Project root for resolving binaries. Defaults to cwd. + #[arg(long)] + project_root: Option, + /// Per-sim timeout (e.g. "120s", "5m"). + #[arg(long)] + timeout: Option, +} + +#[allow(clippy::needless_return)] +async fn dispatch_run(r: RunArgs) -> Result<()> { + // On Linux: run natively. + #[cfg(target_os = "linux")] + { + let sim_timeout = r.timeout + .map(|s| native::parse_duration(&s)) + .transpose() + .context("invalid --timeout value")?; + if r.open { + #[cfg(feature = "serve")] + { + let bind_addr = r.bind.clone(); + let work = r.work_dir.clone(); tokio::spawn(async move { - if let Err(e) = patchbay_server::serve(work, &bind_clone).await { + if let Err(e) = patchbay_server::serve(work, &bind_addr).await { tracing::error!("server error: {e}"); } }); + println!("patchbay: http://{}/", r.bind); + let url = format!("http://{}/", r.bind); + let _ = ProcessCommand::new("xdg-open").arg(&url).spawn(); } - let args = patchbay_vm::RunVmArgs { - sim_inputs: sims, - work_dir, - binary_overrides, - verbose, - recreate, - patchbay_version, + #[cfg(not(feature = "serve"))] + bail!("--open requires the `serve` feature"); + } + let project_root = resolve_project_root(r.project_root)?; + let sims = resolve_sim_args(r.sims, &project_root)?; + let res = native::run_sims( + sims, r.work_dir, r.binary_overrides, r.verbose, + Some(project_root), r.no_build, sim_timeout, + ).await; + if r.open && res.is_ok() { + println!("run finished; server still running (Ctrl-C to exit)"); + loop { tokio::time::sleep(Duration::from_secs(60)).await; } + } + return res; + } + + // On non-Linux with VM feature: delegate to VM backend. + #[cfg(all(not(target_os = "linux"), feature = "vm"))] + { + let vm_args = vm::VmRunArgs { + sims: r.sims, work_dir: r.work_dir, binary_overrides: r.binary_overrides, + verbose: r.verbose, open: r.open, bind: r.bind, + }; + return vm::run_sims_vm(vm_args, patchbay_vm::Backend::Auto); + } + + #[cfg(all(not(target_os = "linux"), not(feature = "vm")))] + { let _ = r; bail!("run requires Linux or the `vm` feature"); } +} + +// ── Prepare dispatch ──────────────────────────────────────────────────── + +#[allow(clippy::needless_return)] +async fn dispatch_prepare( + sims: Vec, + work_dir: PathBuf, + binary_overrides: Vec, + no_build: bool, + project_root: Option, +) -> Result<()> { + #[cfg(target_os = "linux")] + { + let project_root = resolve_project_root(project_root)?; + let sims = resolve_sim_args(sims, &project_root)?; + return native::prepare_sims(sims, work_dir, binary_overrides, Some(project_root), no_build) + .await; + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (&sims, &work_dir, &binary_overrides, &no_build, &project_root); + bail!("prepare requires Linux (use `patchbay vm run` for non-Linux)"); + } +} + +// ── Test dispatch ─────────────────────────────────────────────────────── + +#[allow(clippy::needless_return)] +fn dispatch_test( + args: test::TestArgs, + persist: bool, + vm: Option, + verbose: bool, +) -> Result<()> { + // Explicit --vm: force VM backend. + if let Some(ref vm_backend) = vm { + #[cfg(feature = "vm")] + { + let backend = match vm_backend.as_str() { + "auto" => patchbay_vm::Backend::Auto.resolve(), + "qemu" => patchbay_vm::Backend::Qemu, + "container" => patchbay_vm::Backend::Container, + other => bail!("unknown VM backend: {other}"), }; - let res = backend.run_sims(args); - if open && res.is_ok() { - println!("run finished; server still running (Ctrl-C to exit)"); - loop { - tokio::time::sleep(Duration::from_secs(60)).await; - } - } - res + return test::run_vm(args, backend); } - VmCommand::Serve { - work_dir, - testdir, - bind, - open, + #[cfg(not(feature = "vm"))] + { + let _ = vm_backend; + bail!("VM support not compiled (enable the `vm` feature)"); + } + } + + // No --vm flag: auto-detect based on platform. + #[cfg(target_os = "linux")] + { + return test::run_native(args, verbose, persist); + } + + #[cfg(all(not(target_os = "linux"), feature = "vm"))] + { + let _ = (verbose, persist); + let backend = patchbay_vm::Backend::Auto.resolve(); + return test::run_vm(args, backend); + } + + #[cfg(all(not(target_os = "linux"), not(feature = "vm")))] + { + let _ = (args, verbose, persist); + bail!("test requires Linux or the `vm` feature"); + } +} + +// ── Compare dispatch ──────────────────────────────────────────────────── + +fn dispatch_compare(command: CompareCommand, verbose: bool) -> Result<()> { + let cwd = std::env::current_dir().context("get cwd")?; + let work_dir = cwd.join(".patchbay/work"); + match command { + CompareCommand::Test { + left_ref, + right_ref, + force_build, + no_ref_build, + args, } => { - let dir = if testdir { - work_dir - .join("binaries") - .join("tests") - .join("testdir-current") + use patchbay_utils::manifest::{self as mf, RunKind}; + + let right_label = right_ref.as_deref().unwrap_or("worktree"); + println!( + "patchbay compare test: {} \u{2194} {}", + left_ref, right_label + ); + + let resolve_ref_results = + |git_ref: &str, label: &str| -> Result> { + let sha = mf::resolve_ref(git_ref) + .with_context(|| format!("could not resolve ref '{git_ref}'"))?; + + if !force_build { + if let Some((_dir, manifest)) = + mf::find_run_for_commit(&work_dir, &sha, RunKind::Test) + { + println!("Using cached run for {label} ({sha:.8})"); + return Ok(manifest.tests); + } + } + + if no_ref_build { + bail!( + "no cached run for {label} ({sha:.8}); \ + run `patchbay test --persist` on that ref first, \ + or remove --no-ref-build" + ); + } + + println!("Running tests in {label} ..."); + let tree_dir = compare::setup_worktree(git_ref, &cwd)?; + let (results, _output) = + compare::run_tests_in_dir(&tree_dir, &args, verbose)?; + + compare::persist_worktree_run(&tree_dir, &results, &sha)?; + compare::cleanup_worktree(&tree_dir)?; + Ok(results) + }; + + let left_results = resolve_ref_results(&left_ref, &left_ref)?; + + let right_results = if let Some(ref r) = right_ref { + resolve_ref_results(r, r)? } else { - work_dir + println!("Running tests in worktree ..."); + let (results, _output) = + compare::run_tests_in_dir(&cwd, &args, verbose)?; + results }; - println!("patchbay: serving {} at http://{bind}/", dir.display()); - if open { - let url = format!("http://{bind}"); - let _ = open::that(&url); + + let result = compare::compare_results(&left_results, &right_results); + compare::print_summary( + &left_ref, + right_label, + &left_results, + &right_results, + &result, + ); + + if result.regressions > 0 { + bail!("{} regressions detected", result.regressions); } - patchbay_server::serve(dir, &bind).await + Ok(()) } - VmCommand::Test { - filter, - target, - packages, - tests, - jobs, - features, - release, - lib, - no_fail_fast, - recreate, - cargo_args, + CompareCommand::Run { + sims: _, + left_ref: _, + right_ref: _, } => { - let test_args = test::TestArgs { - include_ignored: false, - ignored: false, - packages, - tests, - jobs, - features, - release, - lib, - no_fail_fast, - extra_args: { - let mut args = Vec::new(); - if let Some(f) = filter { - args.push(f); - } - args.extend(cargo_args); - args - }, - }; - backend.run_tests(test_args.into_vm_args(target, recreate)) + bail!("compare run is not yet implemented"); } } } +// ── Helpers ───────────────────────────────────────────────────────────── + /// When no sim paths are given on the CLI, look for `patchbay.toml` or /// `.patchbay.toml` in the project root and use its `simulations` path. fn resolve_sim_args(sims: Vec, project_root: &Path) -> Result> { @@ -699,10 +555,6 @@ struct PatchbayConfig { } /// Resolve `testdir-current` inside the cargo target directory. -/// -/// Runs `cargo metadata` to find the target directory, then appends -/// `testdir-current`. This matches the convention used by the `testdir` -/// crate when running tests natively. #[cfg(feature = "serve")] fn resolve_testdir_native() -> Result { let output = ProcessCommand::new("cargo") @@ -717,350 +569,6 @@ fn resolve_testdir_native() -> Result { serde_json::from_slice(&output.stdout).context("parse cargo metadata")?; let target_dir = meta["target_directory"] .as_str() - .ok_or_else(|| anyhow!("cargo metadata missing target_directory"))?; + .ok_or_else(|| anyhow::anyhow!("cargo metadata missing target_directory"))?; Ok(PathBuf::from(target_dir).join("testdir-current")) } - -#[cfg(target_os = "linux")] -#[derive(Debug, Clone, Serialize, Deserialize)] -struct InspectSession { - prefix: String, - root_ns: String, - node_namespaces: HashMap, - node_ips_v4: HashMap, - node_keeper_pids: HashMap, -} - -#[cfg(target_os = "linux")] -fn inspect_dir(work_dir: &std::path::Path) -> PathBuf { - work_dir.join("inspect") -} - -#[cfg(target_os = "linux")] -fn inspect_session_path(work_dir: &std::path::Path, prefix: &str) -> PathBuf { - inspect_dir(work_dir).join(format!("{prefix}.json")) -} - -#[cfg(target_os = "linux")] -fn env_key_suffix(name: &str) -> String { - patchbay::util::sanitize_for_env_key(name) -} - -#[cfg(target_os = "linux")] -fn load_topology_for_inspect( - input: &std::path::Path, -) -> Result<(patchbay::config::LabConfig, bool)> { - let text = - std::fs::read_to_string(input).with_context(|| format!("read {}", input.display()))?; - let value: toml::Value = - toml::from_str(&text).with_context(|| format!("parse TOML {}", input.display()))?; - let is_sim = - value.get("sim").is_some() || value.get("step").is_some() || value.get("binary").is_some(); - if is_sim { - let sim: sim::SimFile = - toml::from_str(&text).with_context(|| format!("parse sim {}", input.display()))?; - let topo = sim::topology::load_topology(&sim, input) - .with_context(|| format!("load topology from sim {}", input.display()))?; - Ok((topo, true)) - } else { - let topo: patchbay::config::LabConfig = - toml::from_str(&text).with_context(|| format!("parse topology {}", input.display()))?; - Ok((topo, false)) - } -} - -#[cfg(target_os = "linux")] -fn keeper_commmand() -> ProcessCommand { - let mut cmd = ProcessCommand::new("sh"); - cmd.args(["-lc", "while :; do sleep 3600; done"]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - cmd -} - -#[cfg(target_os = "linux")] -async fn inspect_command(input: PathBuf, work_dir: PathBuf) -> Result<()> { - check_caps()?; - - let (topo, is_sim) = load_topology_for_inspect(&input)?; - let lab = patchbay_runner::Lab::from_config(topo.clone()) - .await - .with_context(|| format!("build lab config from {}", input.display()))?; - - let mut node_namespaces = HashMap::new(); - let mut node_ips_v4 = HashMap::new(); - let mut node_keeper_pids = HashMap::new(); - - for router in &topo.router { - let name = router.name.clone(); - let r = lab - .router_by_name(&name) - .with_context(|| format!("unknown router '{name}'"))?; - let child = r.spawn_command_sync(keeper_commmand())?; - node_keeper_pids.insert(name.clone(), child.id()); - node_namespaces.insert(name.clone(), r.ns().to_string()); - if let Some(ip) = r.uplink_ip() { - node_ips_v4.insert(name, ip.to_string()); - } - } - for name in topo.device.keys() { - let d = lab - .device_by_name(name) - .with_context(|| format!("unknown device '{name}'"))?; - let child = d.spawn_command_sync(keeper_commmand())?; - node_keeper_pids.insert(name.clone(), child.id()); - node_namespaces.insert(name.clone(), d.ns().to_string()); - if let Some(ip) = d.ip() { - node_ips_v4.insert(name.clone(), ip.to_string()); - } - } - - let prefix = lab.prefix().to_string(); - let session = InspectSession { - prefix: prefix.clone(), - root_ns: lab.ix().ns(), - node_namespaces, - node_ips_v4, - node_keeper_pids, - }; - - let session_dir = inspect_dir(&work_dir); - std::fs::create_dir_all(&session_dir) - .with_context(|| format!("create {}", session_dir.display()))?; - let session_path = inspect_session_path(&work_dir, &prefix); - std::fs::write(&session_path, serde_json::to_vec_pretty(&session)?) - .with_context(|| format!("write {}", session_path.display()))?; - - let mut keys = session - .node_namespaces - .keys() - .map(|k| k.to_string()) - .collect::>(); - keys.sort(); - - println!( - "inspect ready: {} ({})", - session.prefix, - if is_sim { "sim" } else { "topology" } - ); - println!("session file: {}", session_path.display()); - println!("export NETSIM_INSPECT={}", session.prefix); - println!("export NETSIM_INSPECT_FILE={}", session_path.display()); - for key in &keys { - if let Some(ns) = session.node_namespaces.get(key) { - println!("export NETSIM_NS_{}={ns}", env_key_suffix(key)); - } - if let Some(ip) = session.node_ips_v4.get(key) { - println!("export NETSIM_IP_{}={ip}", env_key_suffix(key)); - } - } - println!("inspect active; press Ctrl-C to stop and clean up"); - loop { - std::thread::sleep(Duration::from_secs(60)); - } -} - -#[cfg(target_os = "linux")] -fn resolve_inspect_ref(inspect: Option) -> Result { - if let Some(value) = inspect { - let trimmed = value.trim(); - if trimmed.is_empty() { - bail!("--inspect must not be empty"); - } - return Ok(trimmed.to_string()); - } - let from_env = std::env::var("NETSIM_INSPECT") - .context("missing inspect session; set --inspect or NETSIM_INSPECT")?; - let trimmed = from_env.trim(); - if trimmed.is_empty() { - bail!("NETSIM_INSPECT is set but empty"); - } - Ok(trimmed.to_string()) -} - -#[cfg(target_os = "linux")] -fn load_inspect_session(work_dir: &std::path::Path, inspect_ref: &str) -> Result { - let as_path = PathBuf::from(inspect_ref); - let session_path = if as_path.extension().and_then(|v| v.to_str()) == Some("json") - || inspect_ref.contains('/') - { - as_path - } else { - inspect_session_path(work_dir, inspect_ref) - }; - let bytes = std::fs::read(&session_path) - .with_context(|| format!("read inspect session {}", session_path.display()))?; - serde_json::from_slice(&bytes) - .with_context(|| format!("parse inspect session {}", session_path.display())) -} - -#[cfg(target_os = "linux")] -fn run_in_command( - node: String, - inspect: Option, - work_dir: PathBuf, - cmd: Vec, -) -> Result<()> { - check_caps()?; - if cmd.is_empty() { - bail!("run-in: missing command"); - } - let inspect_ref = resolve_inspect_ref(inspect)?; - let session = load_inspect_session(&work_dir, &inspect_ref)?; - let pid = *session.node_keeper_pids.get(&node).ok_or_else(|| { - anyhow!( - "node '{}' is not in inspect session '{}'", - node, - session.prefix - ) - })?; - - let mut proc = ProcessCommand::new("nsenter"); - proc.arg("-U") - .arg("-t") - .arg(pid.to_string()) - .arg("-n") - .arg("--") - .arg(&cmd[0]); - if cmd.len() > 1 { - proc.args(&cmd[1..]); - } - let status = proc - .status() - .context("run command with nsenter for inspect session")?; - if !status.success() { - bail!("run-in command exited with status {}", status); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::*; - - #[cfg(target_os = "linux")] - #[test] - fn env_key_suffix_normalizes_names() { - assert_eq!(env_key_suffix("relay"), "relay"); - assert_eq!(env_key_suffix("fetcher-1"), "fetcher_1"); - } - - #[cfg(target_os = "linux")] - #[test] - fn inspect_session_path_uses_prefix_json() { - let base = PathBuf::from("/tmp/patchbay-work"); - let path = inspect_session_path(&base, "lab-p123"); - assert!(path.ends_with("inspect/lab-p123.json")); - } - - #[cfg(target_os = "linux")] - fn write_temp_file(dir: &Path, rel: &str, body: &str) -> PathBuf { - let path = dir.join(rel); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent"); - } - std::fs::write(&path, body).expect("write file"); - path - } - - #[cfg(target_os = "linux")] - #[test] - fn inspect_loader_detects_sim_input() { - let root = std::env::temp_dir().join(format!( - "patchbay-inspect-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("time") - .as_nanos() - )); - let sim_path = write_temp_file( - &root, - "sims/case.toml", - "[sim]\nname='x'\n\n[[router]]\nname='relay'\n\n[device.fetcher.eth0]\ngateway='relay'\n", - ); - let (_topo, is_sim) = load_topology_for_inspect(&sim_path).expect("load sim topology"); - assert!(is_sim); - } - - #[cfg(target_os = "linux")] - #[test] - fn inspect_loader_detects_topology_input() { - let root = std::env::temp_dir().join(format!( - "patchbay-inspect-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("time") - .as_nanos() - )); - let topo_path = write_temp_file( - &root, - "topos/lab.toml", - "[[router]]\nname='relay'\n\n[device.fetcher.eth0]\ngateway='relay'\n", - ); - let (_topo, is_sim) = load_topology_for_inspect(&topo_path).expect("load direct topology"); - assert!(!is_sim); - } - - #[tokio::test(flavor = "current_thread")] - async fn iperf_sim_writes_results_with_mbps() { - let root = std::env::temp_dir().join(format!( - "patchbay-iperf-run-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("time") - .as_nanos() - )); - std::fs::create_dir_all(&root).expect("create temp workdir"); - let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .to_path_buf(); - let sim_path = workspace_root.join("iroh-integration/patchbay/sims/iperf-1to1-public.toml"); - let project_root = workspace_root; - sim::run_sims( - vec![sim_path], - root.clone(), - vec![], - false, - Some(project_root), - false, - None, - ) - .await - .expect("run iperf sim"); - - let run_root = std::fs::canonicalize(root.join("latest")).expect("resolve latest"); - let results_path = run_root - .join("iperf-1to1-public-baseline") - .join("results.json"); - let text = std::fs::read_to_string(&results_path) - .unwrap_or_else(|e| panic!("read {}: {e}", results_path.display())); - let json: serde_json::Value = serde_json::from_str(&text).expect("parse results"); - let step = &json["steps"][0]; - let down_bytes: f64 = step["down_bytes"] - .as_str() - .expect("down_bytes should be present") - .parse() - .expect("down_bytes should be numeric"); - let duration: f64 = step["duration"] - .as_str() - .expect("duration should be present") - .parse::() - .map(|us| us as f64 / 1_000_000.0) - .unwrap_or_else(|_| { - step["duration"] - .as_str() - .unwrap() - .parse::() - .expect("duration as float") - }); - let mb_s = down_bytes / (duration * 1_000_000.0); - assert!(mb_s > 0.0, "expected mb_s > 0, got {mb_s}"); - } -} diff --git a/patchbay-cli/src/native.rs b/patchbay-cli/src/native.rs new file mode 100644 index 0000000..7f94467 --- /dev/null +++ b/patchbay-cli/src/native.rs @@ -0,0 +1,371 @@ +//! Native Linux backend: sim execution and interactive namespace inspection. +//! +//! Everything in this module requires Linux user namespaces (patchbay core). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use patchbay::check_caps; +use patchbay_runner::sim; +use serde::{Deserialize, Serialize}; + +/// Initialize user namespaces (must be called before tokio starts threads). +pub fn init() -> Result<()> { + patchbay::init_userns() +} + +/// Run one or more sims locally. +pub async fn run_sims( + sims: Vec, + work_dir: PathBuf, + binary_overrides: Vec, + verbose: bool, + project_root: Option, + no_build: bool, + timeout: Option, +) -> Result<()> { + sim::run_sims(sims, work_dir, binary_overrides, verbose, project_root, no_build, timeout).await +} + +/// Resolve sims and build all required assets without running. +pub async fn prepare_sims( + sims: Vec, + work_dir: PathBuf, + binary_overrides: Vec, + project_root: Option, + no_build: bool, +) -> Result<()> { + sim::prepare_sims(sims, work_dir, binary_overrides, project_root, no_build).await +} + +/// Parse a duration string like "120s" or "5m". +pub fn parse_duration(s: &str) -> Result { + sim::steps::parse_duration(s) +} + +// ── Inspect / RunIn ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InspectSession { + pub prefix: String, + pub root_ns: String, + pub node_namespaces: HashMap, + pub node_ips_v4: HashMap, + pub node_keeper_pids: HashMap, +} + +pub fn inspect_dir(work_dir: &Path) -> PathBuf { + work_dir.join("inspect") +} + +pub fn inspect_session_path(work_dir: &Path, prefix: &str) -> PathBuf { + inspect_dir(work_dir).join(format!("{prefix}.json")) +} + +pub fn env_key_suffix(name: &str) -> String { + patchbay::util::sanitize_for_env_key(name) +} + +pub fn load_topology_for_inspect( + input: &Path, +) -> Result<(patchbay::config::LabConfig, bool)> { + let text = std::fs::read_to_string(input) + .with_context(|| format!("read {}", input.display()))?; + let value: toml::Value = + toml::from_str(&text).with_context(|| format!("parse TOML {}", input.display()))?; + let is_sim = + value.get("sim").is_some() || value.get("step").is_some() || value.get("binary").is_some(); + if is_sim { + let sim_file: sim::SimFile = + toml::from_str(&text).with_context(|| format!("parse sim {}", input.display()))?; + let topo = sim::topology::load_topology(&sim_file, input) + .with_context(|| format!("load topology from sim {}", input.display()))?; + Ok((topo, true)) + } else { + let topo: patchbay::config::LabConfig = + toml::from_str(&text).with_context(|| format!("parse topology {}", input.display()))?; + Ok((topo, false)) + } +} + +fn keeper_command() -> ProcessCommand { + let mut cmd = ProcessCommand::new("sh"); + cmd.args(["-lc", "while :; do sleep 3600; done"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + cmd +} + +pub async fn inspect_command(input: PathBuf, work_dir: PathBuf) -> Result<()> { + check_caps()?; + + let (topo, is_sim) = load_topology_for_inspect(&input)?; + let lab = patchbay_runner::Lab::from_config(topo.clone()) + .await + .with_context(|| format!("build lab config from {}", input.display()))?; + + let mut node_namespaces = HashMap::new(); + let mut node_ips_v4 = HashMap::new(); + let mut node_keeper_pids = HashMap::new(); + + for router in &topo.router { + let name = router.name.clone(); + let r = lab + .router_by_name(&name) + .with_context(|| format!("unknown router '{name}'"))?; + let child = r.spawn_command_sync(keeper_command())?; + node_keeper_pids.insert(name.clone(), child.id()); + node_namespaces.insert(name.clone(), r.ns().to_string()); + if let Some(ip) = r.uplink_ip() { + node_ips_v4.insert(name, ip.to_string()); + } + } + for name in topo.device.keys() { + let d = lab + .device_by_name(name) + .with_context(|| format!("unknown device '{name}'"))?; + let child = d.spawn_command_sync(keeper_command())?; + node_keeper_pids.insert(name.clone(), child.id()); + node_namespaces.insert(name.clone(), d.ns().to_string()); + if let Some(ip) = d.ip() { + node_ips_v4.insert(name.clone(), ip.to_string()); + } + } + + let prefix = lab.prefix().to_string(); + let session = InspectSession { + prefix: prefix.clone(), + root_ns: lab.ix().ns(), + node_namespaces, + node_ips_v4, + node_keeper_pids, + }; + + let session_dir = inspect_dir(&work_dir); + std::fs::create_dir_all(&session_dir) + .with_context(|| format!("create {}", session_dir.display()))?; + let session_path = inspect_session_path(&work_dir, &prefix); + std::fs::write(&session_path, serde_json::to_vec_pretty(&session)?) + .with_context(|| format!("write {}", session_path.display()))?; + + let mut keys: Vec<_> = session.node_namespaces.keys().map(String::as_str).collect(); + keys.sort(); + + println!( + "inspect ready: {} ({})", + session.prefix, + if is_sim { "sim" } else { "topology" } + ); + println!("session file: {}", session_path.display()); + println!("export NETSIM_INSPECT={}", session.prefix); + println!("export NETSIM_INSPECT_FILE={}", session_path.display()); + for key in &keys { + if let Some(ns) = session.node_namespaces.get(*key) { + println!("export NETSIM_NS_{}={ns}", env_key_suffix(key)); + } + if let Some(ip) = session.node_ips_v4.get(*key) { + println!("export NETSIM_IP_{}={ip}", env_key_suffix(key)); + } + } + println!("inspect active; press Ctrl-C to stop and clean up"); + loop { + std::thread::sleep(Duration::from_secs(60)); + } +} + +pub fn resolve_inspect_ref(inspect: Option) -> Result { + if let Some(value) = inspect { + let trimmed = value.trim(); + if trimmed.is_empty() { + bail!("--inspect must not be empty"); + } + return Ok(trimmed.to_string()); + } + let from_env = std::env::var("NETSIM_INSPECT") + .context("missing inspect session; set --inspect or NETSIM_INSPECT")?; + let trimmed = from_env.trim(); + if trimmed.is_empty() { + bail!("NETSIM_INSPECT is set but empty"); + } + Ok(trimmed.to_string()) +} + +pub fn load_inspect_session(work_dir: &Path, inspect_ref: &str) -> Result { + let as_path = PathBuf::from(inspect_ref); + let session_path = if as_path.extension().and_then(|v| v.to_str()) == Some("json") + || inspect_ref.contains('/') + { + as_path + } else { + inspect_session_path(work_dir, inspect_ref) + }; + let bytes = std::fs::read(&session_path) + .with_context(|| format!("read inspect session {}", session_path.display()))?; + serde_json::from_slice(&bytes) + .with_context(|| format!("parse inspect session {}", session_path.display())) +} + +pub fn run_in_command( + node: String, + inspect: Option, + work_dir: PathBuf, + cmd: Vec, +) -> Result<()> { + check_caps()?; + if cmd.is_empty() { + bail!("run-in: missing command"); + } + let inspect_ref = resolve_inspect_ref(inspect)?; + let session = load_inspect_session(&work_dir, &inspect_ref)?; + let pid = *session.node_keeper_pids.get(&node).ok_or_else(|| { + anyhow!( + "node '{}' is not in inspect session '{}'", + node, + session.prefix + ) + })?; + + let mut proc = ProcessCommand::new("nsenter"); + proc.arg("-U") + .arg("-t") + .arg(pid.to_string()) + .arg("-n") + .arg("--") + .arg(&cmd[0]); + if cmd.len() > 1 { + proc.args(&cmd[1..]); + } + let status = proc + .status() + .context("run command with nsenter for inspect session")?; + if !status.success() { + bail!("run-in command exited with status {}", status); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_key_suffix_normalizes_names() { + assert_eq!(env_key_suffix("relay"), "relay"); + assert_eq!(env_key_suffix("fetcher-1"), "fetcher_1"); + } + + #[test] + fn inspect_session_path_uses_prefix_json() { + let base = PathBuf::from("/tmp/patchbay-work"); + let path = inspect_session_path(&base, "lab-p123"); + assert!(path.ends_with("inspect/lab-p123.json")); + } + + fn write_temp_file(dir: &Path, rel: &str, body: &str) -> PathBuf { + let path = dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(&path, body).expect("write file"); + path + } + + #[test] + fn inspect_loader_detects_sim_input() { + let root = std::env::temp_dir().join(format!( + "patchbay-inspect-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + let sim_path = write_temp_file( + &root, + "sims/case.toml", + "[sim]\nname='x'\n\n[[router]]\nname='relay'\n\n[device.fetcher.eth0]\ngateway='relay'\n", + ); + let (_topo, is_sim) = load_topology_for_inspect(&sim_path).expect("load sim topology"); + assert!(is_sim); + } + + #[test] + fn inspect_loader_detects_topology_input() { + let root = std::env::temp_dir().join(format!( + "patchbay-inspect-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + let topo_path = write_temp_file( + &root, + "topos/lab.toml", + "[[router]]\nname='relay'\n\n[device.fetcher.eth0]\ngateway='relay'\n", + ); + let (_topo, is_sim) = load_topology_for_inspect(&topo_path).expect("load direct topology"); + assert!(!is_sim); + } + + #[tokio::test(flavor = "current_thread")] + async fn iperf_sim_writes_results_with_mbps() { + let root = std::env::temp_dir().join(format!( + "patchbay-iperf-run-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("create temp workdir"); + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf(); + let sim_path = workspace_root.join("iroh-integration/patchbay/sims/iperf-1to1-public.toml"); + run_sims( + vec![sim_path], + root.clone(), + vec![], + false, + Some(workspace_root), + false, + None, + ) + .await + .expect("run iperf sim"); + + let run_root = std::fs::canonicalize(root.join("latest")).expect("resolve latest"); + let results_path = run_root + .join("iperf-1to1-public-baseline") + .join("results.json"); + let text = std::fs::read_to_string(&results_path) + .unwrap_or_else(|e| panic!("read {}: {e}", results_path.display())); + let json: serde_json::Value = serde_json::from_str(&text).expect("parse results"); + let step = &json["steps"][0]; + let down_bytes: f64 = step["down_bytes"] + .as_str() + .expect("down_bytes should be present") + .parse() + .expect("down_bytes should be numeric"); + let duration: f64 = step["duration"] + .as_str() + .expect("duration should be present") + .parse::() + .map(|us| us as f64 / 1_000_000.0) + .unwrap_or_else(|_| { + step["duration"] + .as_str() + .unwrap() + .parse::() + .expect("duration as float") + }); + let mb_s = down_bytes / (duration * 1_000_000.0); + assert!(mb_s > 0.0, "expected mb_s > 0, got {mb_s}"); + } +} diff --git a/patchbay-cli/src/vm.rs b/patchbay-cli/src/vm.rs new file mode 100644 index 0000000..cbc23ac --- /dev/null +++ b/patchbay-cli/src/vm.rs @@ -0,0 +1,195 @@ +//! VM backend: subcommands and dispatch for patchbay-vm. + +use std::{path::PathBuf, time::Duration}; + +use anyhow::Result; +use clap::Subcommand; +use patchbay_vm::VmOps; + +use crate::test; + +#[cfg(feature = "serve")] +use patchbay_server::DEFAULT_UI_BIND; +#[cfg(not(feature = "serve"))] +const DEFAULT_UI_BIND: &str = "127.0.0.1:7421"; + +/// Shared args for `patchbay run` (used by both top-level Run and Vm Run). +#[derive(Debug, Clone, clap::Args)] +pub struct VmRunArgs { + /// One or more sim TOML files or directories containing `*.toml`. + #[arg(required = true)] + pub sims: Vec, + + /// Work directory for logs, binaries, and results. + #[arg(long, default_value = ".patchbay/work")] + pub work_dir: PathBuf, + + /// Binary override in `::` form. + #[arg(long = "binary")] + pub binary_overrides: Vec, + + /// Stream live stdout/stderr lines with node prefixes. + #[arg(short = 'v', long, default_value_t = false)] + pub verbose: bool, + + /// Start embedded UI server and open browser. + #[arg(long, default_value_t = false)] + pub open: bool, + + /// Bind address for embedded UI server. + #[arg(long, default_value = DEFAULT_UI_BIND)] + pub bind: String, +} + +/// VM sub-subcommands (mirrors patchbay-vm's standalone CLI). +#[derive(Subcommand)] +pub enum VmCommand { + /// Boot or reuse VM and ensure mounts. + Up { + #[arg(long)] + recreate: bool, + }, + /// Stop VM and helper processes. + Down, + /// Show VM running status. + Status, + /// Best-effort cleanup of VM helper artifacts/processes. + Cleanup, + /// Execute command in the guest (SSH for QEMU, exec for container). + Ssh { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + cmd: Vec, + }, + /// Run one or more sims in VM using guest patchbay binary. + Run { + #[command(flatten)] + args: VmRunArgs, + + #[arg(long)] + recreate: bool, + #[arg(long, default_value = "latest")] + patchbay_version: String, + }, + /// Serve embedded UI + work directory over HTTP. + Serve { + #[arg(long, default_value = ".patchbay/work")] + work_dir: PathBuf, + /// Serve `/binaries/tests/testdir-current` instead of work_dir. + #[arg(long, default_value_t = false)] + testdir: bool, + #[arg(long, default_value = DEFAULT_UI_BIND)] + bind: String, + #[arg(long, default_value_t = false)] + open: bool, + }, + /// Build and run tests in VM. + Test { + #[command(flatten)] + args: test::TestArgs, + #[arg(long, default_value_t = patchbay_vm::default_test_target())] + target: String, + #[arg(long)] + recreate: bool, + }, +} + +/// Dispatch VM subcommands to the patchbay-vm library. +pub async fn dispatch_vm(command: VmCommand, backend: patchbay_vm::Backend) -> Result<()> { + let backend = backend.resolve(); + + match command { + VmCommand::Up { recreate } => backend.up(recreate), + VmCommand::Down => backend.down(), + VmCommand::Status => backend.status(), + VmCommand::Cleanup => backend.cleanup(), + VmCommand::Ssh { cmd } => backend.exec(cmd), + VmCommand::Run { + args, + recreate, + patchbay_version, + } => { + if args.open { + #[cfg(feature = "serve")] + { + let url = format!("http://{}", args.bind); + println!("patchbay UI: {url}"); + let _ = open::that(&url); + let work = args.work_dir.clone(); + let bind_clone = args.bind.clone(); + tokio::spawn(async move { + if let Err(e) = patchbay_server::serve(work, &bind_clone).await { + tracing::error!("server error: {e}"); + } + }); + } + #[cfg(not(feature = "serve"))] + bail!("--open requires the `serve` feature"); + } + let vm_args = patchbay_vm::RunVmArgs { + sim_inputs: args.sims, + work_dir: args.work_dir.clone(), + binary_overrides: args.binary_overrides, + verbose: args.verbose, + recreate, + patchbay_version, + }; + let res = backend.run_sims(vm_args); + if args.open && res.is_ok() { + println!("run finished; server still running (Ctrl-C to exit)"); + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + } + } + res + } + VmCommand::Serve { + work_dir, + testdir, + bind, + open, + } => { + #[cfg(feature = "serve")] + { + let dir = if testdir { + work_dir + .join("binaries") + .join("tests") + .join("testdir-current") + } else { + work_dir + }; + println!("patchbay: serving {} at http://{bind}/", dir.display()); + if open { + let url = format!("http://{bind}"); + let _ = open::that(&url); + } + patchbay_server::serve(dir, &bind).await + } + #[cfg(not(feature = "serve"))] + { + let _ = (&work_dir, &testdir, &bind, &open); + bail!("serve requires the `serve` feature") + } + } + VmCommand::Test { + args, + target, + recreate, + } => backend.run_tests(args.into_vm_args(target, recreate)), + } +} + +/// Run sims via VM backend (used by top-level `Run` on non-Linux). +#[allow(dead_code)] // Only called on non-Linux targets. +pub fn run_sims_vm(args: VmRunArgs, backend: patchbay_vm::Backend) -> Result<()> { + let backend = backend.resolve(); + let vm_args = patchbay_vm::RunVmArgs { + sim_inputs: args.sims, + work_dir: args.work_dir, + binary_overrides: args.binary_overrides, + verbose: args.verbose, + recreate: false, + patchbay_version: "latest".to_string(), + }; + backend.run_sims(vm_args) +} From 521c01d88a12920dc4635fc2eb0d3b8f2aa1a393 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 27 Mar 2026 15:09:52 +0100 Subject: [PATCH 2/3] fix: use direct GitHub release download instead of cargo binstall Replace cargo binstall with a direct curl+tar from the rolling release. Faster, no extra tooling, and works reliably in CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/testing.md | 16 +++++++++++----- patchbay-server/github-workflow-template.yml | 12 +++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/guide/testing.md b/docs/guide/testing.md index ffce27f..b241043 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -112,8 +112,11 @@ On Linux, tests run natively. Install patchbay's CLI if you want the `serve` command for viewing results: ```bash -cargo binstall patchbay-cli --no-confirm \ - || cargo install patchbay-cli --git https://github.com/n0-computer/patchbay +# From rolling release (fast): +curl -fsSL https://github.com/n0-computer/patchbay/releases/download/rolling/patchbay-x86_64-unknown-linux-musl.tar.gz \ + | tar xz -C ~/.cargo/bin && mv ~/.cargo/bin/patchbay-x86_64-unknown-linux-musl ~/.cargo/bin/patchbay +# Or build from source: +cargo install patchbay-cli --git https://github.com/n0-computer/patchbay ``` Then run your tests and serve the output: @@ -247,11 +250,14 @@ Install the patchbay CLI in your workflow, then add these steps **after** the test step: ```yaml - # Install patchbay CLI (binstall for speed, cargo install as fallback) + # Install patchbay CLI from rolling release - name: Install patchbay CLI run: | - cargo binstall patchbay-cli --no-confirm 2>/dev/null \ - || cargo install patchbay-cli --git https://github.com/n0-computer/patchbay + ASSET="patchbay-x86_64-unknown-linux-musl" + curl -fsSL "https://github.com/n0-computer/patchbay/releases/download/rolling/${ASSET}.tar.gz" \ + | tar xz -C /usr/local/bin "$ASSET" + mv /usr/local/bin/"$ASSET" /usr/local/bin/patchbay + chmod +x /usr/local/bin/patchbay # Run tests with patchbay (--persist keeps the run directory) - name: Run tests diff --git a/patchbay-server/github-workflow-template.yml b/patchbay-server/github-workflow-template.yml index c4a0aeb..1706be9 100644 --- a/patchbay-server/github-workflow-template.yml +++ b/patchbay-server/github-workflow-template.yml @@ -29,13 +29,15 @@ jobs: # ── Build tools — adjust to your project ── - uses: dtolnay/rust-toolchain@stable - # ── Install patchbay CLI ── - # Install pre-built binary via binstall (fast), or build from source. + # ── Install patchbay CLI from rolling release ── - name: Install patchbay CLI run: | - curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash - cargo binstall patchbay-cli --git-url https://github.com/n0-computer/patchbay --no-confirm \ - || cargo install patchbay-cli --git https://github.com/n0-computer/patchbay + ASSET="patchbay-x86_64-unknown-linux-musl" + URL="https://github.com/n0-computer/patchbay/releases/download/rolling/${ASSET}.tar.gz" + curl -fsSL "$URL" | tar xz -C /usr/local/bin "$ASSET" + mv /usr/local/bin/"$ASSET" /usr/local/bin/patchbay + chmod +x /usr/local/bin/patchbay + patchbay --version || patchbay --help | head -1 # ── Run tests — replace with your own command ── # Use `patchbay test` which writes structured output to .patchbay/work/. From 29affa490b5d637c26a791d41ad6936b69396e72 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 27 Mar 2026 15:11:17 +0100 Subject: [PATCH 3/3] fix: restore ctor bootstrap, use direct release download in CI Add back #[ctor::ctor] init_userns_for_ctor() in native.rs so test binaries spawned by nextest set up user namespaces before main(). Replace cargo binstall with direct curl+tar from rolling release. Co-Authored-By: Claude Opus 4.6 (1M context) --- patchbay-cli/src/native.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/patchbay-cli/src/native.rs b/patchbay-cli/src/native.rs index 7f94467..ab39d9e 100644 --- a/patchbay-cli/src/native.rs +++ b/patchbay-cli/src/native.rs @@ -12,7 +12,15 @@ use patchbay::check_caps; use patchbay_runner::sim; use serde::{Deserialize, Serialize}; -/// Initialize user namespaces (must be called before tokio starts threads). +/// Bootstrap user namespaces before main() — required for test binaries +/// where main() is not our code (nextest spawns each test as a process). +#[ctor::ctor] +fn _init_userns() { + // Safety: called from .init_array before main() and before any threads. + unsafe { patchbay::init_userns_for_ctor() }; +} + +/// Initialize user namespaces (called from main() as well for the CLI binary). pub fn init() -> Result<()> { patchbay::init_userns() }