diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6900c4dea..f58040ff1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,24 @@ jobs: - uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest + - name: Build and pin sandbox plugins + env: + PLUGIN_TARGET: ${{ matrix.target }} + USE_ZIG: ${{ matrix.musl && 'true' || 'false' }} + run: | + args=(--target "$PLUGIN_TARGET") + if [[ "$USE_ZIG" == true ]]; then args+=(--zigbuild); fi + cargo --locked dev plugins "${args[@]}" + plugin_dir="$GITHUB_WORKSPACE/target/$PLUGIN_TARGET/plugins/bin" + mkdir -p "target/$PLUGIN_TARGET/release" + cp "$plugin_dir/"sandbox-driver-* "target/$PLUGIN_TARGET/release/" + echo "PETRI_SANDBOX_PLUGIN_DIR=$plugin_dir" >> "$GITHUB_ENV" + echo "PETRI_SANDBOX_HOST_PLUGIN=$plugin_dir/sandbox-driver-host" >> "$GITHUB_ENV" + echo "PETRI_SANDBOX_DOCKER_PLUGIN=$plugin_dir/sandbox-driver-docker" >> "$GITHUB_ENV" + echo "PETRI_SANDBOX_DAYTONA_PLUGIN=$plugin_dir/sandbox-driver-daytona" >> "$GITHUB_ENV" + echo "PETRI_SANDBOX_PLUGIN_DEV=0" >> "$GITHUB_ENV" + echo "FABRO_REQUIRE_SANDBOX_PLUGINS=1" >> "$GITHUB_ENV" + - name: Refresh embedded SPA run: cargo --locked dev spa refresh @@ -120,9 +138,13 @@ jobs: run: | mkdir fabro-${{ matrix.target }} cp target/${{ matrix.target }}/release/fabro fabro-${{ matrix.target }}/ + cp target/${{ matrix.target }}/plugins/bin/sandbox-driver-* fabro-${{ matrix.target }}/ tar czf fabro-${{ matrix.target }}.tar.gz fabro-${{ matrix.target }} shasum -a 256 fabro-${{ matrix.target }}.tar.gz > fabro-${{ matrix.target }}.tar.gz.sha256 + - name: Smoke-test packaged workflow and checksum enforcement + run: python3 test/bin/bundle_smoke.py fabro-${{ matrix.target }}/fabro ${{ matrix.target }} + - name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: @@ -192,7 +214,7 @@ jobs: esac mkdir -p "tmp/docker-context/$arch" tar -xzf "target/distrib/fabro-${target}.tar.gz" -C target/distrib - cp "target/distrib/fabro-${target}/fabro" "tmp/docker-context/$arch/fabro" + cp "target/distrib/fabro-${target}/fabro" "target/distrib/fabro-${target}/"sandbox-driver-* "tmp/docker-context/$arch/" done - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 @@ -224,7 +246,11 @@ jobs: tags: fabro-smoke:amd64 - name: Smoke-test amd64 image - run: docker run --rm --platform linux/amd64 fabro-smoke:amd64 fabro version + run: | + docker run --rm --platform linux/amd64 fabro-smoke:amd64 sh -ec ' + fabro version + for kind in host docker daytona; do test -x "/usr/local/bin/sandbox-driver-$kind"; done + ' - name: Build arm64 image for smoke test uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 @@ -236,7 +262,11 @@ jobs: tags: fabro-smoke:arm64 - name: Smoke-test arm64 image - run: docker run --rm --platform linux/arm64 fabro-smoke:arm64 fabro version + run: | + docker run --rm --platform linux/arm64 fabro-smoke:arm64 sh -ec ' + fabro version + for kind in host docker daytona; do test -x "/usr/local/bin/sandbox-driver-$kind"; done + ' - id: build uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a5c64bb4..8fe779417 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,10 @@ The instructions below will help you build and test Fabro locally. ### Build and test ```bash +# Build Fabro with its embedded web UI and matching sandbox plugins +bun install --frozen-lockfile +cargo dev build + # Build all Rust crates cargo build --workspace @@ -35,6 +39,32 @@ cargo fmt --check --all cargo clippy --workspace -- -D warnings ``` +`cargo dev build` builds the sandbox-driver executables from the revision in +`Cargo.lock`, embeds their SHA-256 hashes in Fabro through Petri, and copies +the plugins beside the resulting executable. Use `cargo dev build -- --release` +for a release bundle. Keep all four executables together when copying a build +to another directory or machine. + +For release-mode tests, build the plugins before compiling the test binaries: + +```bash +cargo dev plugins --target aarch64-apple-darwin +export PETRI_SANDBOX_PLUGIN_DIR="$PWD/target/aarch64-apple-darwin/plugins/bin" +export PETRI_SANDBOX_HOST_PLUGIN="$PETRI_SANDBOX_PLUGIN_DIR/sandbox-driver-host" +export PETRI_SANDBOX_DOCKER_PLUGIN="$PETRI_SANDBOX_PLUGIN_DIR/sandbox-driver-docker" +export PETRI_SANDBOX_DAYTONA_PLUGIN="$PETRI_SANDBOX_PLUGIN_DIR/sandbox-driver-daytona" +export PETRI_SANDBOX_PLUGIN_DEV=0 +unset PETRI_SANDBOX_HOST_SHA256 PETRI_SANDBOX_DOCKER_SHA256 PETRI_SANDBOX_DAYTONA_SHA256 +mkdir -p target/release +cp "$PETRI_SANDBOX_PLUGIN_DIR/"sandbox-driver-* target/release/ +cargo nextest run --workspace --release --profile ci +``` + +Replace the target triple with your platform. `cargo dev release` prepares +these build inputs and runtime paths automatically for its release smoke test. +Avoid rebuilding the plugins between compiling Fabro and assembling its bundle: +the embedded hashes must describe the bytes you distribute. + ### Web frontend (fabro-web) ```bash diff --git a/Cargo.lock b/Cargo.lock index ee5855cf8..bbbe21ef4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2253,6 +2253,8 @@ dependencies = [ "fabro-options-metadata", "fabro-util", "quick-xml 0.36.2", + "serde", + "serde_json", "tempfile", "toml_edit", "tracing-subscriber", diff --git a/Dockerfile b/Dockerfile index 5fa65d8aa..b5e7b973e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ # Binaries are supplied pre-built via the release workflow: # tmp/docker-context/amd64/fabro (x86_64-unknown-linux-musl) # tmp/docker-context/arm64/fabro (aarch64-unknown-linux-musl) +# Each directory also contains the three matching sandbox-driver-* plugins. # # The image serves the HTTP API (with embedded web UI) on $PORT (default # 32276), persists state to /storage, and runs as the unprivileged `fabro` @@ -26,7 +27,7 @@ RUN apk add --no-cache \ && adduser -S -u 1000 -G fabro -h /var/fabro -s /sbin/nologin fabro \ && install -d -o fabro -g fabro -m 0755 /var/fabro /storage -COPY --chmod=0755 tmp/docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro +COPY --chmod=0755 tmp/docker-context/${TARGETARCH}/fabro tmp/docker-context/${TARGETARCH}/sandbox-driver-* /usr/local/bin/ COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint diff --git a/README.md b/README.md index 73a1e4ffc..b3ac93f0d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Then run `fabro server start` to finish setup in your browser. The server opens | 🌲 | Git checkpointing | Every stage commits code changes and execution metadata to Git branches. Resume, revert, or trace any change | | 📊 | Run observability | Durable events, checkpoints, conclusions, and stage outputs make every run inspectable and exportable | | ⚡ | Comprehensive API | REST API with SSE event streaming and a React web UI. Run workflows programmatically or as a service | -| 🦀 | Single binary, no runtime | One compiled Rust executable with zero dependencies. No Python, no Node, no Docker required | +| 🦀 | Native Rust executables | Fabro and its matching sandbox executables ship together. No Python or Node runtime required | | ⚖️ | Open source (MIT) | Full source code, no vendor lock-in. Self-host, fork, or extend to fit your workflow | --- diff --git a/apps/marketing/public/install.sh b/apps/marketing/public/install.sh index 6fbbbce25..826fa6dc3 100755 --- a/apps/marketing/public/install.sh +++ b/apps/marketing/public/install.sh @@ -67,8 +67,10 @@ case "$OS" in esac ASSET="fabro-${TARGET}.tar.gz" -TMPDIR="$(mktemp -d)" -trap 'rm -rf "$TMPDIR"' EXIT +download_dir="$(mktemp -d)" +bundle_dir="" +activation_dir="" +trap 'rm -rf "$download_dir"; [ -z "$activation_dir" ] || rm -rf "$activation_dir"' EXIT TAG="$(gh api "repos/${REPO}/releases/latest" --jq '.tag_name')" if [ -z "$TAG" ]; then @@ -76,23 +78,56 @@ if [ -z "$TAG" ]; then fi dim "Downloading fabro for ${TARGET}..." -gh release download "$TAG" --repo "$REPO" --pattern "$ASSET" --dir "$TMPDIR" --clobber +gh release download "$TAG" --repo "$REPO" --pattern "$ASSET" --dir "$download_dir" --clobber dim "Extracting..." -tar xzf "${TMPDIR}/${ASSET}" -C "$TMPDIR" +tar xzf "${download_dir}/${ASSET}" -C "$download_dir" -# --- Install binary --- +# --- Stage a complete, immutable installation --- INSTALL_DIR="${FABRO_INSTALL_DIR:-$HOME/.fabro/bin}" mkdir -p "$INSTALL_DIR" -mv "${TMPDIR}/fabro-${TARGET}/fabro" "${INSTALL_DIR}/fabro" - -chmod +x "${INSTALL_DIR}/fabro" - -# --- Verify --- -VERSION="$("${INSTALL_DIR}/fabro" --version 2>/dev/null || true)" -if [ -z "$VERSION" ]; then - error "Installation failed: could not run fabro --version" +INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd -P)" +source_dir="${download_dir}/fabro-${TARGET}" +[ -f "$source_dir/fabro" ] && [ ! -L "$source_dir/fabro" ] || error "Release is missing the fabro executable" +# Older stable archives contain only fabro. Accept those, but never a +# partially delivered plugin bundle. +plugin_count=0 +for kind in host docker daytona; do + if [ -e "$source_dir/sandbox-driver-$kind" ] || [ -L "$source_dir/sandbox-driver-$kind" ]; then + plugin_count=$((plugin_count + 1)) + fi +done +[ "$plugin_count" -eq 0 ] || [ "$plugin_count" -eq 3 ] || error "Release has an incomplete sandbox plugin bundle" +if [ "$plugin_count" -eq 3 ]; then + for kind in host docker daytona; do + plugin="$source_dir/sandbox-driver-$kind" + [ -f "$plugin" ] && [ ! -L "$plugin" ] && [ -x "$plugin" ] || error "Invalid sandbox plugin: $kind" + done fi +mkdir -p "$INSTALL_DIR/.fabro-versions" +bundle_dir="$(mktemp -d "$INSTALL_DIR/.fabro-versions/bundle-XXXXXXXX")" +cp "$source_dir/fabro" "$bundle_dir/fabro" +chmod +x "$bundle_dir/fabro" +if [ "$plugin_count" -eq 3 ]; then + for kind in host docker daytona; do + cp "$source_dir/sandbox-driver-$kind" "$bundle_dir/" + done +fi +# mktemp creates a private directory; shared install locations must remain +# traversable by the users who could execute the previous installation. +chmod 755 "$bundle_dir" +VERSION="$("$bundle_dir/fabro" --version 2>/dev/null)" || error "Installation failed: could not run fabro --version" +[ -n "$VERSION" ] || error "Installation failed: could not run fabro --version" +# Keep the old bundle for servers still using it. A flat old executable is +# retained too; do not modify existing sibling plugins. +if [ -f "$INSTALL_DIR/fabro" ] && [ ! -L "$INSTALL_DIR/fabro" ]; then + previous_dir="$(mktemp -d "$INSTALL_DIR/.fabro-versions/previous-XXXXXXXX")" + ln "$INSTALL_DIR/fabro" "$previous_dir/fabro" +fi +activation_dir="$(mktemp -d "$INSTALL_DIR/.fabro-activate-XXXXXXXX")" +ln -s ".fabro-versions/$(basename "$bundle_dir")/fabro" "$activation_dir/fabro" +# -f replaces the launcher itself; an existing bundle remains immutable. +mv -f "$activation_dir/fabro" "$INSTALL_DIR/fabro" tildify() { if [ "${1#"$HOME"/}" != "$1" ]; then diff --git a/docs/public/reference/verifying-releases.mdx b/docs/public/reference/verifying-releases.mdx index f6c1d5ff2..8c18879b4 100644 --- a/docs/public/reference/verifying-releases.mdx +++ b/docs/public/reference/verifying-releases.mdx @@ -40,3 +40,36 @@ Replace `` with the release version (e.g. `0.207.0`) or use `latest` / ## Homebrew installs The `fabro-sh/tap` Homebrew formula verifies a SHA-256 checksum against each tarball Homebrew downloads, so there is no separate attestation step to run at install time. If you want provenance verification for a Homebrew-installed release, download the matching tarball from the [releases page](https://github.com/fabro-sh/fabro/releases) and run `gh attestation verify` against it. + +## Sandbox plugin checksums + +Release archives contain `fabro`, `sandbox-driver-host`, `sandbox-driver-docker`, +and `sandbox-driver-daytona`. These are Fabro's required sandbox executables, +built from the pinned sandbox-driver crates. The release build embeds the SHA-256 hashes of +those exact plugin binaries in Fabro. Workflow execution checks a plugin's +bytes before launching it and rejects a checksum mismatch. + +Keep the four files from one release together. The shell installer, Homebrew, +Docker image, and bundle-aware `fabro upgrade` install the matching files. +Shell and self-upgrade installations retain previous bundles so running servers +can keep using their original plugins; restart the server after upgrading to +use the new bundle. + +Older self-updaters copy only `fabro`. After upgrading from one of those +versions, finish the transition with the new updater: + +```bash +fabro upgrade +``` + +When sandbox executables are missing, this downloads and verifies the complete +bundle for the installed version, including its nightly version if applicable. +It does not change versions during repair. Use `fabro upgrade --dry-run` to +preview the repair. Explicit `--version` and `--prerelease` requests still select +their requested release. Subsequent upgrades replace the complete bundle. +Server startup and `fabro doctor` show the repair command when +bundled sandbox executables are missing. Homebrew users should use the corresponding +`brew reinstall` command shown by that diagnostic. + +For a checksum mismatch, install a complete matching release bundle. Do not +enable plugin development mode to work around a damaged or mixed installation. diff --git a/installer/fabro-nightly.rb.template b/installer/fabro-nightly.rb.template index f37c3d1e0..47820e620 100644 --- a/installer/fabro-nightly.rb.template +++ b/installer/fabro-nightly.rb.template @@ -25,7 +25,7 @@ class FabroNightly < Formula end def install - bin.install "fabro" + bin.install "fabro", "sandbox-driver-host", "sandbox-driver-docker", "sandbox-driver-daytona" end test do diff --git a/installer/fabro.rb.template b/installer/fabro.rb.template index 46ea04817..25ee38c37 100644 --- a/installer/fabro.rb.template +++ b/installer/fabro.rb.template @@ -23,7 +23,7 @@ class Fabro < Formula end def install - bin.install "fabro" + bin.install "fabro", "sandbox-driver-host", "sandbox-driver-docker", "sandbox-driver-daytona" end test do diff --git a/lib/apps/fabro-cli/src/commands/doctor.rs b/lib/apps/fabro-cli/src/commands/doctor.rs index 4765c7da1..037f46672 100644 --- a/lib/apps/fabro-cli/src/commands/doctor.rs +++ b/lib/apps/fabro-cli/src/commands/doctor.rs @@ -254,11 +254,14 @@ pub(crate) async fn run_doctor( let settings_config_path = active_settings_path(None); - let local_checks = vec![check_config( + let mut local_checks = vec![check_config( settings_config_path .exists() .then_some(settings_config_path), )]; + if let Some(check) = super::upgrade::incomplete_bundle_check() { + local_checks.push(check); + } let mut report = CheckReport { title: "Fabro Doctor".to_string(), diff --git a/lib/apps/fabro-cli/src/commands/server/start.rs b/lib/apps/fabro-cli/src/commands/server/start.rs index 233465885..55a257081 100644 --- a/lib/apps/fabro-cli/src/commands/server/start.rs +++ b/lib/apps/fabro-cli/src/commands/server/start.rs @@ -23,6 +23,7 @@ use tokio::process::Command as TokioCommand; use tokio::task::spawn_blocking; use tokio::time; +use crate::commands::upgrade; use crate::local_server; const SERVER_START_HEALTH_PROBE_TIMEOUT: Duration = Duration::from_millis(250); @@ -44,6 +45,13 @@ pub(crate) async fn execute( ) -> Result<()> { serve_args.bind = Some(bind.to_string()); + if let Some(check) = upgrade::incomplete_bundle_check() { + fabro_util::printerr!(printer, "Warning: {}.", check.summary); + if let Some(remediation) = check.remediation { + fabro_util::printerr!(printer, "{remediation}"); + } + } + if foreground { Box::pin(execute_foreground( bind, diff --git a/lib/apps/fabro-cli/src/commands/upgrade.rs b/lib/apps/fabro-cli/src/commands/upgrade.rs index 4a8360075..c0275f6a6 100644 --- a/lib/apps/fabro-cli/src/commands/upgrade.rs +++ b/lib/apps/fabro-cli/src/commands/upgrade.rs @@ -15,6 +15,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use fabro_types::settings::CliNamespace; use fabro_types::settings::cli::OutputFormat; +use fabro_util::check_report::{CheckDetail, CheckResult, CheckStatus}; use fabro_util::printer::Printer; use semver::Version; use sha2::{Digest, Sha256}; @@ -22,6 +23,9 @@ use tokio::process::Command as TokioCommand; use tokio::task::JoinHandle; use tracing::debug; +mod bundle; +pub(crate) use bundle::enter_managed_bundle; + use crate::args::UpgradeArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; @@ -30,6 +34,54 @@ use crate::shared::print_json_pretty; const GITHUB_REPO: &str = "fabro-sh/fabro"; +/// Packaged builds can arrive without their companions through an old updater. +/// Keep this diagnostic separate from execution: explicit plugin configurations +/// still work, and release checksum enforcement remains Petri's responsibility. +pub(crate) fn incomplete_bundle_check() -> Option { + option_env!("PETRI_SANDBOX_PLUGIN_DIR")?; + let executable = std::env::current_exe().ok()?.canonicalize().ok()?; + incomplete_bundle_check_at(&executable) +} + +fn incomplete_bundle_check_at(executable: &Path) -> Option { + let missing = missing_bundle_executables(executable); + if missing.is_empty() { + return None; + } + let remediation = match detect_install_source(executable) { + InstallSource::Tarball => "fabro upgrade".to_owned(), + InstallSource::Brew { + channel: BrewChannel::Stable, + } => "brew reinstall fabro".to_owned(), + InstallSource::Brew { + channel: BrewChannel::Nightly, + } => "brew reinstall fabro-nightly".to_owned(), + }; + Some(CheckResult { + name: "Installation bundle".to_owned(), + status: CheckStatus::Warning, + summary: "bundled sandbox executables are missing".to_owned(), + details: vec![CheckDetail::new(format!( + "Missing: {}. Older updaters copy only the Fabro executable.", + missing.join(", ") + ))], + remediation: Some(format!( + "Finish installing the matching bundle with `{remediation}`, then restart the server." + )), + }) +} + +fn missing_bundle_executables(executable: &Path) -> Vec<&'static str> { + let Some(directory) = executable.parent() else { + return Vec::new(); + }; + bundle::EXECUTABLES + .iter() + .copied() + .filter(|name| *name != "fabro" && !directory.join(name).is_file()) + .collect() +} + enum Backend { Gh, Http(fabro_http::HttpClient), @@ -458,12 +510,20 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Resu let current = Version::parse(env!("CARGO_PKG_VERSION")).context("failed to parse current version")?; + // Only packaged builds promise companions. A bare `cargo build` is not a + // damaged installation, and runtime plugin overrides do not change whether + // the installed release bundle itself is complete. + let incomplete_bundle = option_env!("PETRI_SANDBOX_PLUGIN_DIR").is_some() + && !missing_bundle_executables(¤t_exe).is_empty(); - // Determine target version + // Repair an incomplete install before looking for newer releases. In + // particular, a nightly must not accidentally switch to the stable channel. let (target, tag) = if let Some(ref v) = args.version { let version = parse_version_from_tag(v)?; let tag = format!("v{version}"); (version, tag) + } else if incomplete_bundle && !args.prerelease { + (current.clone(), format!("v{current}")) } else { let tag = if args.prerelease { match pick_latest_tag(&backend.fetch_releases().await?) { @@ -499,7 +559,7 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Resu bail!("downgrade requires interactive confirmation (stdin is not a tty)"); } } - std::cmp::Ordering::Equal if !args.force => { + std::cmp::Ordering::Equal if !args.force && !incomplete_bundle => { if cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "previous_version": current.to_string(), @@ -513,6 +573,7 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Resu _ => {} } + let repairing = incomplete_bundle && target == current; if args.dry_run { if cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ @@ -521,7 +582,14 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Resu "dry_run": true, }))?; } else { - fabro_util::printerr!(printer, "Would upgrade fabro from {current} to {target}"); + if repairing { + fabro_util::printerr!( + printer, + "Would repair the fabro {current} installation (missing sandbox executables)" + ); + } else { + fabro_util::printerr!(printer, "Would upgrade fabro from {current} to {target}"); + } fabro_util::printerr!(printer, " tag: {tag}"); fabro_util::printerr!(printer, " target: {}", detect_target()?); } @@ -532,7 +600,8 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Resu let tarball_name = format!("fabro-{triple}.tar.gz"); let checksum_name = format!("{tarball_name}.sha256"); - let exe_dir = current_exe + let launcher = bundle::launcher(¤t_exe)?; + let exe_dir = launcher .parent() .context("could not determine executable directory")?; @@ -541,6 +610,12 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Resu .context("failed to create temp directory")?; // Download tarball and checksum in parallel + if repairing { + fabro_util::printerr!( + printer, + "Repairing the fabro {current} installation (missing sandbox executables)..." + ); + } fabro_util::printerr!(printer, "Downloading fabro {target}..."); let (tarball_path, checksum_path) = tokio::try_join!( backend.download_release(&tag, &tarball_name, tmp_dir.path()), @@ -568,33 +643,20 @@ pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Resu bail!("tar extraction failed"); } - // Atomic binary replacement - let extracted_binary = tmp_dir.path().join(format!("fabro-{triple}")).join("fabro"); - let backup = exe_dir.join(".fabro-upgrade-backup"); - fs::rename(¤t_exe, &backup).context("failed to move current binary to backup")?; - if let Err(e) = fs::rename(&extracted_binary, ¤t_exe) { - // Restore from backup - if let Err(restore_err) = fs::rename(&backup, ¤t_exe) { - bail!( - "failed to install new binary ({e}) and failed to restore backup ({restore_err})" - ); - } - bail!("failed to install new binary: {e}"); - } - let _ = fs::remove_file(&backup); - - // Set permissions - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(¤t_exe, fs::Permissions::from_mode(0o755)); - } + let extracted_bundle = tmp_dir.path().join(format!("fabro-{triple}")); + bundle::install(&extracted_bundle, &launcher)?; + fabro_util::printerr!( + printer, + "Restart the Fabro server to use the new bundle. Previous bundles are retained for running processes." + ); if cli.output.format == OutputFormat::Json { print_json_pretty(&serde_json::json!({ "previous_version": current.to_string(), "installed_version": target.to_string(), }))?; + } else if repairing { + fabro_util::printerr!(printer, "Repaired fabro {target}"); } else { fabro_util::printerr!(printer, "Upgraded fabro to {target}"); } @@ -1225,4 +1287,39 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("Homebrew")); } + + #[test] + fn incomplete_bundle_reports_plain_upgrade_for_repair() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("fabro"); + let check = incomplete_bundle_check_at(&executable).unwrap(); + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.remediation.as_deref(), + Some( + "Finish installing the matching bundle with `fabro upgrade`, then restart the server." + ) + ); + for kind in ["host", "docker", "daytona"] { + fs::write( + root.path().join(format!("sandbox-driver-{kind}")), + b"plugin", + ) + .unwrap(); + } + assert!(incomplete_bundle_check_at(&executable).is_none()); + } + + #[test] + fn incomplete_homebrew_bundle_uses_homebrew_for_repair() { + let check = + incomplete_bundle_check_at(Path::new("/test/Cellar/fabro-nightly/1.0/bin/fabro")) + .unwrap(); + assert_eq!( + check.remediation.as_deref(), + Some( + "Finish installing the matching bundle with `brew reinstall fabro-nightly`, then restart the server." + ) + ); + } } diff --git a/lib/apps/fabro-cli/src/commands/upgrade/bundle.rs b/lib/apps/fabro-cli/src/commands/upgrade/bundle.rs new file mode 100644 index 000000000..fbb4a5dba --- /dev/null +++ b/lib/apps/fabro-cli/src/commands/upgrade/bundle.rs @@ -0,0 +1,233 @@ +//! Install immutable executable bundles and atomically switch the launcher. +//! Retained bundles keep a running server's plugin fingerprints valid. + +#![expect( + clippy::disallowed_methods, + reason = "the synchronous upgrade installation stages and activates executable files" +)] + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +const VERSIONS_DIR: &str = ".fabro-versions"; +pub(super) const EXECUTABLES: [&str; 4] = [ + "fabro", + "sandbox-driver-host", + "sandbox-driver-docker", + "sandbox-driver-daytona", +]; + +/// macOS can report the launcher symlink from `current_exe`. Enter its actual +/// bundle before starting threads, so this process and all later workers keep +/// discovering plugins beside this version even after the launcher changes. +#[cfg(unix)] +pub(crate) fn enter_managed_bundle() -> std::io::Result<()> { + use std::os::unix::process::CommandExt; + use std::process::Command; + + let executable = std::env::current_exe()?; + let canonical = executable.canonicalize()?; + let managed = canonical + .parent() + .and_then(Path::parent) + .is_some_and(|parent| parent.file_name().is_some_and(|name| name == VERSIONS_DIR)); + if managed && canonical != executable { + return Err(Command::new(canonical) + .args(std::env::args_os().skip(1)) + .exec()); + } + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn enter_managed_bundle() -> std::io::Result<()> { + Ok(()) +} + +/// Locate the public launcher from either a managed bundle or a flat install. +pub(super) fn launcher(current_exe: &Path) -> Result { + let parent = current_exe + .parent() + .context("Fabro executable has no parent directory")?; + if let Some(versions) = parent + .parent() + .filter(|path| path.file_name().is_some_and(|name| name == VERSIONS_DIR)) + { + let directory = versions + .parent() + .context("bundle directory has no installation root")?; + let launcher = directory.join("fabro"); + if launcher + .canonicalize() + .context("resolving the active Fabro bundle")? + != current_exe + { + bail!( + "this Fabro bundle is no longer active; retry upgrade using {}", + launcher.display() + ); + } + Ok(launcher) + } else { + Ok(current_exe.to_path_buf()) + } +} + +#[cfg(unix)] +pub(super) fn install(source: &Path, launcher: &Path) -> Result<()> { + use std::os::unix::fs::{self as unix_fs, PermissionsExt}; + + for name in EXECUTABLES { + let path = source.join(name); + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("release bundle is missing {name}; install a release containing the complete sandbox plugin bundle"))?; + if !metadata.file_type().is_file() || metadata.permissions().mode() & 0o111 == 0 { + bail!( + "release bundle entry {} must be a regular executable file", + path.display() + ); + } + } + let directory = launcher + .parent() + .context("Fabro launcher has no parent directory")?; + let versions = directory.join(VERSIONS_DIR); + fs::create_dir_all(&versions).with_context(|| format!("creating {}", versions.display()))?; + let staged = tempfile::Builder::new() + .prefix("bundle-") + .tempdir_in(&versions) + .context("staging the new Fabro bundle")?; + for name in EXECUTABLES { + fs::copy(source.join(name), staged.path().join(name)) + .with_context(|| format!("staging {name}"))?; + } + // TempDir starts private. A system-wide install must remain executable by + // other users after activation, just like its public launcher directory. + fs::set_permissions(staged.path(), fs::Permissions::from_mode(0o755)) + .context("setting bundle directory permissions")?; + // A flat installation has no retained version yet. Preserve its old + // executable before activation; existing plugin siblings remain untouched. + if fs::symlink_metadata(launcher).is_ok_and(|metadata| metadata.file_type().is_file()) { + let previous = tempfile::Builder::new() + .prefix("previous-") + .tempdir_in(&versions) + .context("creating the previous executable backup")?; + fs::hard_link(launcher, previous.path().join("fabro")) + .context("preserving the previous Fabro executable")?; + let _ = previous.keep(); + } + let link_directory = tempfile::Builder::new() + .prefix(".fabro-activate-") + .tempdir_in(directory) + .context("staging the Fabro launcher")?; + let link = link_directory.path().join("fabro"); + let relative = Path::new(VERSIONS_DIR) + .join( + staged + .path() + .file_name() + .context("staged bundle has no name")?, + ) + .join("fabro"); + unix_fs::symlink(relative, &link).context("creating the new Fabro launcher")?; + // Keep before activation: an interruption must never leave a launcher + // referring to a directory a TempDir destructor subsequently removes. + let retained = staged.keep(); + if let Err(error) = fs::rename(&link, launcher) { + let _ = fs::remove_dir_all(&retained); + return Err(error) + .context("activating the new Fabro bundle; the previous installation is unchanged"); + } + Ok(()) +} + +#[cfg(not(unix))] +pub(super) fn install(_source: &Path, _launcher: &Path) -> Result<()> { + bail!("Fabro bundle installation requires a supported Unix platform") +} + +#[cfg(all(test, unix))] +mod tests { + use std::os::unix::fs::{self as unix_fs, PermissionsExt}; + + use super::*; + + fn fixture(path: &Path, content: &[u8]) { + fs::create_dir_all(path).expect("create bundle fixture directory"); + for name in EXECUTABLES { + fs::write(path.join(name), content).expect("write bundle fixture executable"); + fs::set_permissions(path.join(name), fs::Permissions::from_mode(0o755)) + .expect("make bundle fixture executable"); + } + } + + #[test] + fn successive_upgrades_retain_matching_executables_for_running_servers() { + let root = tempfile::tempdir().unwrap(); + let canonical_root = root.path().canonicalize().unwrap(); + let source = canonical_root.join("download"); + let destination = canonical_root.join("bin"); + fixture(&destination, b"old"); + fixture(&source, b"first"); + let entry = destination.join("fabro"); + install(&source, &entry).unwrap(); + let first_exe = entry.canonicalize().unwrap(); + assert_eq!( + fs::metadata(first_exe.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + assert_eq!(launcher(&first_exe).unwrap(), entry); + fixture(&source, b"second"); + install(&source, &entry).unwrap(); + let second_exe = entry.canonicalize().unwrap(); + assert_ne!(first_exe, second_exe); + for name in EXECUTABLES { + assert_eq!( + fs::read(first_exe.parent().unwrap().join(name)).unwrap(), + b"first" + ); + assert_eq!( + fs::read(second_exe.parent().unwrap().join(name)).unwrap(), + b"second" + ); + } + assert!(launcher(&first_exe).is_err()); + assert_eq!(launcher(&second_exe).unwrap(), entry); + } + + #[test] + fn missing_or_symlinked_plugin_leaves_old_installation_unchanged() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("download"); + let destination = root.path().join("bin"); + fixture(&source, b"new"); + fixture(&destination, b"old"); + let plugin = source.join("sandbox-driver-daytona"); + fs::remove_file(&plugin).unwrap(); + assert!(install(&source, &destination.join("fabro")).is_err()); + unix_fs::symlink(source.join("fabro"), plugin).unwrap(); + assert!(install(&source, &destination.join("fabro")).is_err()); + for name in EXECUTABLES { + assert_eq!(fs::read(destination.join(name)).unwrap(), b"old"); + } + assert!(!destination.join(VERSIONS_DIR).exists()); + } + + #[test] + fn activation_failure_does_not_remove_existing_destination() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("download"); + fixture(&source, b"new"); + let entry = root.path().join("fabro"); + fs::create_dir(&entry).unwrap(); + fs::write(entry.join("keep"), b"old").unwrap(); + assert!(install(&source, &entry).is_err()); + assert_eq!(fs::read(entry.join("keep")).unwrap(), b"old"); + } +} diff --git a/lib/apps/fabro-cli/src/main.rs b/lib/apps/fabro-cli/src/main.rs index d4c13101e..9d061fe3b 100644 --- a/lib/apps/fabro-cli/src/main.rs +++ b/lib/apps/fabro-cli/src/main.rs @@ -39,9 +39,18 @@ use tracing::debug; use crate::command_context::CommandContext; +#[expect(clippy::print_stderr, reason = "fatal error reporting before exit")] +fn main() { + if let Err(error) = commands::upgrade::enter_managed_bundle() { + eprintln!("Could not enter the installed Fabro bundle: {error}"); + std::process::exit(1); + } + run_cli(); +} + #[expect(clippy::print_stderr, reason = "fatal error reporting before exit")] #[tokio::main] -async fn main() { +async fn run_cli() { let raw_args: Vec = std::env::args().collect(); let subcommand = raw_args.get(1).map(String::as_str); let subcommand_arg = raw_args.get(2).map(String::as_str); diff --git a/lib/apps/fabro-cli/tests/it/cmd/upgrade.rs b/lib/apps/fabro-cli/tests/it/cmd/upgrade.rs index b75ebb779..18883f5ac 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/upgrade.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/upgrade.rs @@ -11,7 +11,7 @@ fn hard_link_or_copy(src: &std::path::Path, dest: &std::path::Path) { return; } - std::fs::copy(src, dest).expect("copy test binary into fake Cellar"); + std::fs::copy(src, dest).expect("copy test binary into installation fixture"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -32,14 +32,29 @@ fn brew_command(context: &TestContext, formula: &str, version: &str) -> Command .join(formula) .join(version) .join("bin"); - std::fs::create_dir_all(&bin_dir).expect("create fake Cellar bin dir"); - let brew_fabro = bin_dir.join("fabro"); + installed_command(context, &bin_dir) +} + +fn complete_bundle_command(context: &TestContext) -> Command { + let bin_dir = context.temp_dir.join("install"); + let command = installed_command(context, &bin_dir); + // These tests inspect upgrade selection only; they never execute a sandbox. + for kind in ["host", "docker", "daytona"] { + std::fs::write(bin_dir.join(format!("sandbox-driver-{kind}")), b"fixture") + .expect("write sandbox executable fixture"); + } + command +} + +fn installed_command(context: &TestContext, bin_dir: &std::path::Path) -> Command { + std::fs::create_dir_all(bin_dir).expect("create installation fixture bin dir"); + let installed_fabro = bin_dir.join("fabro"); hard_link_or_copy( std::path::Path::new(env!("CARGO_BIN_EXE_fabro")), - &brew_fabro, + &installed_fabro, ); - let mut cmd = Command::new(&brew_fabro); + let mut cmd = Command::new(&installed_fabro); cmd.current_dir(&context.temp_dir); for (key, _) in std::env::vars_os() { if let Some(s) = key.to_str() { @@ -116,7 +131,7 @@ fn upgrade_already_on_current_version_short_circuits() { regex::escape(env!("CARGO_PKG_VERSION")), "[VERSION]".to_string(), )); - let mut cmd = context.command(); + let mut cmd = complete_bundle_command(&context); cmd.args(["upgrade", "--version", env!("CARGO_PKG_VERSION")]); fabro_snapshot!(filters, cmd, @" @@ -188,7 +203,7 @@ esac fake_bin.display(), std::env::var(EnvVars::PATH).unwrap() ); - let mut cmd = context.command(); + let mut cmd = complete_bundle_command(&context); cmd.env(EnvVars::PATH, path).args(["upgrade", "--dry-run"]); fabro_snapshot!(filters, cmd, @" diff --git a/lib/foundation/fabro-dev/Cargo.toml b/lib/foundation/fabro-dev/Cargo.toml index 7334acbf9..350ea14aa 100644 --- a/lib/foundation/fabro-dev/Cargo.toml +++ b/lib/foundation/fabro-dev/Cargo.toml @@ -35,6 +35,8 @@ fabro-config = { path = "../fabro-config" } fabro-util = { path = "../fabro-util" } fabro-options-metadata.workspace = true quick-xml = "0.36" +serde.workspace = true +serde_json.workspace = true toml_edit.workspace = true tracing-subscriber.workspace = true walkdir.workspace = true diff --git a/lib/foundation/fabro-dev/src/commands/build.rs b/lib/foundation/fabro-dev/src/commands/build.rs index 2ad0b5af5..86d745a8a 100644 --- a/lib/foundation/fabro-dev/src/commands/build.rs +++ b/lib/foundation/fabro-dev/src/commands/build.rs @@ -1,7 +1,9 @@ -use anyhow::Result; +use std::path::PathBuf; + +use anyhow::{Context, Result}; use clap::Args; -use super::{PlannedCommand, spa_refresh}; +use super::{PlannedCommand, plugins, spa_refresh}; #[derive(Debug, Args)] pub(crate) struct BuildArgs { @@ -10,14 +12,56 @@ pub(crate) struct BuildArgs { cargo_args: Vec, } +#[expect( + clippy::disallowed_methods, + reason = "dev tooling reads Cargo's configured target" +)] pub(crate) fn build(args: BuildArgs) -> Result<()> { let root = super::workspace_root(); spa_refresh::spa_refresh_root(&root)?; + let target = + argument(&args.cargo_args, "--target").or_else(|| std::env::var("CARGO_BUILD_TARGET").ok()); + let directory = plugins::prepare(&root, target.as_deref(), false)?; + let profile = argument(&args.cargo_args, "--profile").unwrap_or_else(|| { + if args + .cargo_args + .iter() + .any(|arg| arg == "--release" || arg == "-r") + { + "release".to_owned() + } else { + "debug".to_owned() + } + }); + let output_profile = if profile == "dev" { "debug" } else { &profile }; + let default_root = directory + .parent() + .and_then(std::path::Path::parent) + .and_then(std::path::Path::parent) + .context("plugin staging directory has no Cargo target root")?; + let mut output_root = argument(&args.cargo_args, "--target-dir") + .map(PathBuf::from) + .map_or_else(|| default_root.to_path_buf(), |path| root.join(path)); + if let Some(target) = &target { + output_root.push(target); + } + let output = output_root.join(output_profile); let mut command = PlannedCommand::new("cargo").arg("build"); for arg in args.cargo_args { command = command.arg(arg); } - super::run_command(&root, &command) + super::run_command(&root, &plugins::configure(command, &directory))?; + plugins::stage(&directory, &output) +} + +fn argument(args: &[String], name: &str) -> Option { + args.iter().enumerate().find_map(|(index, arg)| { + if arg == name { + args.get(index + 1).cloned() + } else { + arg.strip_prefix(&format!("{name}=")).map(str::to_owned) + } + }) } diff --git a/lib/foundation/fabro-dev/src/commands/docker_build.rs b/lib/foundation/fabro-dev/src/commands/docker_build.rs index 5f048fe7c..869c53c15 100644 --- a/lib/foundation/fabro-dev/src/commands/docker_build.rs +++ b/lib/foundation/fabro-dev/src/commands/docker_build.rs @@ -199,7 +199,19 @@ impl DockerBuildPlan { .arg("rust:1-bookworm") .arg("cp") .arg(format!("/target/{}/release/fabro", self.arch.target())) - .arg("/out/fabro") + .arg(format!( + "/target/{}/plugins/bin/sandbox-driver-host", + self.arch.target() + )) + .arg(format!( + "/target/{}/plugins/bin/sandbox-driver-docker", + self.arch.target() + )) + .arg(format!( + "/target/{}/plugins/bin/sandbox-driver-daytona", + self.arch.target() + )) + .arg("/out/") } fn image_build_command(&self) -> PlannedCommand { @@ -236,6 +248,7 @@ fn build_script(target: &str, zig_arch: &str) -> String { cargo install --locked --root /opt/cargo-tools cargo-zigbuild; \ fi; \ rustup target add {target}; \ - cargo zigbuild --locked --release -p fabro-cli --target {target}" + cargo --locked dev plugins --target {target} --zigbuild; \ + PETRI_SANDBOX_PLUGIN_DIR=/target/{target}/plugins/bin cargo zigbuild --locked --release -p fabro-cli --target {target}" ) } diff --git a/lib/foundation/fabro-dev/src/commands/mod.rs b/lib/foundation/fabro-dev/src/commands/mod.rs index cb2d9c8cf..721587887 100644 --- a/lib/foundation/fabro-dev/src/commands/mod.rs +++ b/lib/foundation/fabro-dev/src/commands/mod.rs @@ -4,6 +4,7 @@ mod docker_build; mod docs; mod docs_cli_reference; mod docs_options_reference; +mod plugins; mod release; mod spa; mod spa_check; @@ -18,6 +19,7 @@ pub(crate) use build::{BuildArgs, build}; pub(crate) use docker_build::{DockerBuildArgs, docker_build}; pub(crate) use docs::{DocsArgs, docs}; use fabro_util::shell::shell_quote; +pub(crate) use plugins::{PluginsArgs, plugins}; pub(crate) use release::{ReleaseArgs, release}; pub(crate) use spa::{SpaArgs, spa}; diff --git a/lib/foundation/fabro-dev/src/commands/plugins.rs b/lib/foundation/fabro-dev/src/commands/plugins.rs new file mode 100644 index 000000000..762b0c12a --- /dev/null +++ b/lib/foundation/fabro-dev/src/commands/plugins.rs @@ -0,0 +1,222 @@ +//! Build the exact sandbox-driver executables whose hashes Petri embeds. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use clap::Args; +use serde::Deserialize; + +use super::PlannedCommand; + +pub(crate) const BINARIES: [&str; 3] = [ + "sandbox-driver-host", + "sandbox-driver-docker", + "sandbox-driver-daytona", +]; + +#[derive(Debug, Args)] +pub(crate) struct PluginsArgs { + /// Rust target triple (defaults to the active toolchain's host). + #[arg(long)] + target: Option, + /// Use cargo-zigbuild for the plugin binaries (musl release targets). + #[arg(long)] + zigbuild: bool, +} + +#[derive(Deserialize)] +struct Metadata { + packages: Vec, + target_directory: PathBuf, +} + +#[derive(Deserialize)] +struct Package { + name: String, + source: Option, +} + +#[expect( + clippy::print_stdout, + reason = "dev command prints the completed plugin directory" +)] +pub(crate) fn plugins(args: &PluginsArgs) -> Result<()> { + let directory = prepare( + &super::workspace_root(), + args.target.as_deref(), + args.zigbuild, + )?; + println!("Verified-plugin build inputs: {}", directory.display()); + Ok(()) +} + +/// Build first, then copy the exact bytes to an explicit input directory. +/// The caller passes this directory to Cargo and keeps it unchanged until +/// the compiled Fabro and its bundle have both been produced. +pub(crate) fn prepare(root: &Path, target: Option<&str>, zigbuild: bool) -> Result { + let target = target.map(str::to_owned).map_or_else(host_target, Ok)?; + let output = super::capture_command( + root, + &PlannedCommand::new("cargo") + .arg("metadata") + .arg("--locked") + .arg("--format-version") + .arg("1"), + )?; + if !output.status.success() { + bail!( + "resolving pinned sandbox-driver packages failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let metadata: Metadata = serde_json::from_slice(&output.stdout) + .context("reading Cargo metadata for the sandbox plugins")?; + validate_pins(&metadata)?; + let build_dir = metadata.target_directory.join("sandbox-plugins-build"); + let directory = metadata.target_directory.join(&target).join("plugins/bin"); + let mut build = PlannedCommand::new("cargo") + .arg(if zigbuild { "zigbuild" } else { "build" }) + .arg("--locked") + .arg("--release") + .arg("--target") + .arg(&target) + .arg("--target-dir") + .arg(build_dir.to_string_lossy()) + // Resolve the same dependency features as Fabro, including vendored + // OpenSSL on musl. The explicit --bin selectors below build only the + // plugins, before Petri's build script needs their completed bytes. + .arg("-p") + .arg("fabro-cli"); + for binary in BINARIES { + build = build.arg("-p").arg(binary).arg("--bin").arg(binary); + } + super::run_command(root, &build)?; + stage(&build_dir.join(&target).join("release"), &directory)?; + Ok(directory) +} + +fn validate_pins(metadata: &Metadata) -> Result<()> { + let mut packages = Vec::new(); + for binary in BINARIES { + let matches: Vec<_> = metadata + .packages + .iter() + .filter(|package| package.name == binary) + .collect(); + if matches.len() != 1 { + bail!( + "expected exactly one pinned {binary} package, found {}", + matches.len() + ); + } + packages.push(matches[0]); + } + let source = packages[0] + .source + .as_deref() + .context("sandbox plugins must come from the pinned Git dependency")?; + if !source.starts_with("git+") + || !source.contains("?rev=") + || packages + .iter() + .any(|package| package.source.as_deref() != Some(source)) + { + bail!("sandbox plugins must all use the same pinned Git revision"); + } + Ok(()) +} + +pub(crate) fn host_target() -> Result { + let output = super::capture_command(Path::new("."), &PlannedCommand::new("rustc").arg("-vV"))?; + if !output.status.success() { + bail!("rustc -vV failed while detecting the plugin target"); + } + String::from_utf8(output.stdout)? + .lines() + .find_map(|line| line.strip_prefix("host: ").map(str::to_owned)) + .context("rustc -vV did not report a host target") +} + +/// Compile-time pins and test-time discovery must refer to the same bytes. +/// Do not use runtime SHA overrides: the release tests exercise embedded pins. +pub(crate) fn configure(mut command: PlannedCommand, directory: &Path) -> PlannedCommand { + command = command + .env("PETRI_SANDBOX_PLUGIN_DIR", directory.to_string_lossy()) + .env("PETRI_SANDBOX_PLUGIN_DEV", "0") + .env("FABRO_REQUIRE_SANDBOX_PLUGINS", "1"); + for kind in ["host", "docker", "daytona"] { + let upper = kind.to_ascii_uppercase(); + command = command + .env_remove(format!("PETRI_SANDBOX_{upper}_SHA256")) + .env( + format!("PETRI_SANDBOX_{upper}_PLUGIN"), + directory + .join(format!("sandbox-driver-{kind}")) + .to_string_lossy(), + ); + } + command +} + +#[expect( + clippy::disallowed_methods, + reason = "synchronous dev tooling stages release executables" +)] +pub(crate) fn stage(source: &Path, destination: &Path) -> Result<()> { + // Validate the entire input before modifying the destination. + for binary in BINARIES { + let path = source.join(binary); + if !path.is_file() { + bail!("missing sandbox plugin {}", path.display()); + } + } + std::fs::create_dir_all(destination) + .with_context(|| format!("creating {}", destination.display()))?; + for binary in BINARIES { + std::fs::copy(source.join(binary), destination.join(binary)) + .with_context(|| format!("staging {binary} in {}", destination.display()))?; + } + Ok(()) +} + +#[cfg(test)] +#[expect( + clippy::disallowed_methods, + reason = "tests create disposable executable fixtures" +)] +mod tests { + use super::*; + + #[test] + fn mixed_plugin_revisions_are_rejected() { + let mut metadata = Metadata { + target_directory: PathBuf::from("target"), + packages: BINARIES + .iter() + .map(|name| Package { + name: (*name).to_owned(), + source: Some("git+https://example.com/driver?rev=abc#abc".to_owned()), + }) + .collect(), + }; + assert!(validate_pins(&metadata).is_ok()); + metadata.packages[2].source = Some("git+https://example.com/driver?rev=def#def".to_owned()); + assert!(validate_pins(&metadata).is_err()); + } + + #[test] + fn incomplete_build_does_not_modify_existing_bundle() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("source"); + let destination = root.path().join("bundle"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(source.join(BINARIES[0]), "new").unwrap(); + std::fs::write(destination.join(BINARIES[0]), "old").unwrap(); + assert!(stage(&source, &destination).is_err()); + assert_eq!( + std::fs::read(destination.join(BINARIES[0])).unwrap(), + b"old" + ); + } +} diff --git a/lib/foundation/fabro-dev/src/commands/release.rs b/lib/foundation/fabro-dev/src/commands/release.rs index 9883f91af..2d56e9e6a 100644 --- a/lib/foundation/fabro-dev/src/commands/release.rs +++ b/lib/foundation/fabro-dev/src/commands/release.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail}; use chrono::{Local, NaiveDate}; use clap::Args; -use super::{PlannedCommand, capture_command, run_command, spa_refresh, workspace_root}; +use super::{PlannedCommand, capture_command, plugins, run_command, spa_refresh, workspace_root}; const RELEASE_EPOCH: &str = "2026-01-01"; const RELEASE_TEST_SEGMENT_WRITE_KEY: &str = "fake-for-local-smoke"; @@ -538,7 +538,19 @@ impl ReleasePlan { } println!("Running release-mode test smoke (SEGMENT_WRITE_KEY baked in)..."); - run_command(&self.root, &Self::release_tests_command()) + let directory = plugins::prepare(&self.root, None, false)?; + // CLI diagnostics inspect the installed bundle beside the executable, + // even when workflow tests use explicit plugin paths. + let target_root = directory + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .context("plugin staging directory has no Cargo target root")?; + plugins::stage(&directory, &target_root.join("release"))?; + run_command( + &self.root, + &plugins::configure(Self::release_tests_command(), &directory), + ) } #[expect( @@ -552,7 +564,10 @@ impl ReleasePlan { if self.skip_tests { println!("--skip-tests set, would skip release-mode test smoke"); } else { - println!("DRY RUN: would run release-mode test smoke:"); + println!("cargo --locked dev plugins"); + println!( + "DRY RUN: would run release-mode test smoke with embedded plugin pins and explicit plugin paths:" + ); println!("{}", Self::release_tests_command().to_shell_line()); } diff --git a/lib/foundation/fabro-dev/src/lib.rs b/lib/foundation/fabro-dev/src/lib.rs index 60c8716a3..070416d48 100644 --- a/lib/foundation/fabro-dev/src/lib.rs +++ b/lib/foundation/fabro-dev/src/lib.rs @@ -26,6 +26,8 @@ enum Command { DockerBuild(commands::DockerBuildArgs), /// Manage generated reference documentation. Docs(commands::DocsArgs), + /// Build pinned sandbox plugins for a release target. + Plugins(commands::PluginsArgs), /// Run Fabro release automation. Release(commands::ReleaseArgs), /// Manage embedded Fabro web SPA assets. @@ -39,6 +41,7 @@ impl Command { Self::Build(args) => commands::build(args), Self::DockerBuild(args) => commands::docker_build(args), Self::Docs(args) => commands::docs(args), + Self::Plugins(args) => commands::plugins(&args), Self::Release(args) => commands::release(args), Self::Spa(args) => commands::spa(args), } diff --git a/test/bin/bundle_smoke.py b/test/bin/bundle_smoke.py new file mode 100644 index 000000000..3ae19b62e --- /dev/null +++ b/test/bin/bundle_smoke.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Exercise a release bundle without developer plugin paths or checksum overrides.""" + +import hashlib +import json +import os +from pathlib import Path +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import tarfile +import time +import urllib.request + + +def workflow(fabro, root, expect_tampered=False, after_first_run=None): + root.mkdir() + config = root / "settings.toml" + config.write_text( + '_version = 1\n[server.auth]\nmethods = ["dev-token"]\n' + f'[server.storage]\nroot = "{root / "storage"}"\n' + ) + graph = root / "workflow.fabro" + graph.write_text( + 'digraph smoke { start [shape=Mdiamond]; exit [shape=Msquare]; ' + 'check [shape=parallelogram, script="printf bundle-smoke-ok"]; ' + 'start -> check -> exit; }\n' + ) + (root / "workflow.toml").write_text( + '_version = 1\n[workflow]\ngraph = "workflow.fabro"\n' + ) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + server_url = f"http://127.0.0.1:{port}" + token = "fabro_dev_" + "ab" * 32 + # Keep credentials, proxy settings and developer checksum overrides out. + env = { + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "FABRO_HOME": str(root / "home"), + "FABRO_SERVER": server_url, + "FABRO_DEV_TOKEN": token, + "SESSION_SECRET": "0123456789abcdef" * 4, + "FABRO_NO_UPGRADE_CHECK": "true", + "FABRO_TELEMETRY": "off", + "FABRO_SUPPRESS_OPEN_BROWSER": "1", + "FABRO_HTTP_PROXY_POLICY": "disabled", + "NO_COLOR": "1", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + } + + def run(*args): + return subprocess.run( + [str(fabro), *args], cwd=root, env=env, + capture_output=True, text=True, timeout=120, + ) + + log_path = root / "server.log" + with log_path.open("w") as log: + server = subprocess.Popen( + [str(fabro), "server", "start", "--foreground", "--no-web", + "--config", str(config), "--bind", f"127.0.0.1:{port}"], + cwd=root, env=env, stdout=log, stderr=log, start_new_session=True, + ) + try: + client = urllib.request.build_opener(urllib.request.ProxyHandler({})) + deadline = time.monotonic() + 60 + while True: + try: + with client.open(server_url + "/health", timeout=1) as response: + if response.status == 200: + break + except OSError: + pass + if server.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError("server did not start:\n" + log_path.read_text()) + time.sleep(0.1) + + login = run("auth", "login", "--dev-token", token) + if login.returncode: + raise RuntimeError(login.stdout + login.stderr) + result = run("run", str(root / "workflow.toml"), "--environment", "local", "--auto-approve") + transcript = result.stdout + result.stderr + if expect_tampered: + if result.returncode == 0 or "checksum" not in transcript.lower(): + raise RuntimeError("tampered plugin was not rejected for its checksum:\n" + transcript) + elif result.returncode: + raise RuntimeError("packaged workflow failed:\n" + transcript + log_path.read_text()) + if after_first_run: + after_first_run() + result = run("run", str(root / "workflow.toml"), "--environment", "local", "--auto-approve") + if result.returncode: + raise RuntimeError("running server lost its original plugin bundle after an upgrade:\n" + result.stdout + result.stderr) + finally: + # Terminate only the process group created for this isolated server. + try: + os.killpg(server.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + server.wait(timeout=15) + except subprocess.TimeoutExpired: + os.killpg(server.pid, signal.SIGKILL) + server.wait(timeout=15) + + +def upgrade_bundle(source, root, target): + """Exercise the real updater through local, checksummed release assets.""" + assets = root / "assets" + assets.mkdir() + archive = assets / f"fabro-{target}.tar.gz" + with tarfile.open(archive, "w:gz") as output: + output.add(source, arcname=f"fabro-{target}") + digest = hashlib.sha256() + with archive.open("rb") as input_file: + for chunk in iter(lambda: input_file.read(1024 * 1024), b""): + digest.update(chunk) + checksum = assets / (archive.name + ".sha256") + checksum.write_text(f"{digest.hexdigest()} {archive.name}\n") + fake_bin = root / "fake-bin" + fake_bin.mkdir() + gh = fake_bin / "gh" + gh.write_text('''#!/bin/sh +set -eu +case "$1" in + --version) echo 'gh version test';; + auth) exit 0;; + api) + case "$2" in + repos/fabro-sh/fabro/releases/latest) echo "${FAKE_LATEST_STABLE:?unexpected latest-release lookup}";; + repos/fabro-sh/fabro/releases) echo "${FAKE_PRERELEASES:?unexpected prerelease lookup}";; + *) exit 1;; + esac;; + release) + test "$2" = download + test "$3" = "v$FAKE_INSTALLED_VERSION" + asset=''; destination='' + while [ "$#" -gt 0 ]; do + case "$1" in + --pattern) asset="$2"; shift;; + --dir) destination="$2"; shift;; + esac + shift + done + cp "$FAKE_RELEASE_DIR/$asset" "$destination/$asset";; + *) exit 1;; +esac +''') + gh.chmod(0o755) + if target.endswith("-musl"): + # Release CI runs static musl executables on glibc hosts. Model the + # destination distro so the updater selects the musl release asset. + ldd = fake_bin / "ldd" + ldd.write_text("#!/bin/sh\necho musl\n") + ldd.chmod(0o755) + install = root / "install" + install.mkdir() + launcher = install / "fabro" + # A flat executable models what the old, binary-only updater leaves behind. + shutil.copy2(source / "fabro", launcher) + env = { + "PATH": f"{fake_bin}:/usr/bin:/bin:/usr/sbin:/sbin", + "FAKE_RELEASE_DIR": str(assets), + "FABRO_HOME": str(root / "upgrade-home"), + "FABRO_TELEMETRY": "off", + "FABRO_NO_UPGRADE_CHECK": "true", + "NO_COLOR": "1", + } + version = subprocess.check_output([str(launcher), "--version"], env=env, text=True).split()[1] + env["FAKE_INSTALLED_VERSION"] = version + + # Exercise the shipped shell installer with real release executables, then + # run a workflow from the resulting launcher in a clean environment. + clean_install = root / "clean-install" + installer = Path(__file__).resolve().parents[2] / "apps/marketing/public/install.sh" + result = subprocess.run( + ["/bin/sh", str(installer)], env={ + **env, "HOME": str(root / "installer-home"), + "FABRO_INSTALL_DIR": str(clean_install), "FAKE_LATEST_STABLE": f"v{version}", + }, capture_output=True, text=True, timeout=120, + ) + if result.returncode or not (clean_install / "fabro").is_symlink(): + raise RuntimeError("clean bundle installation failed:\n" + result.stdout + result.stderr) + workflow(clean_install / "fabro", root / "installed") + + doctor = subprocess.run( + [str(launcher), "doctor", "--json", "--server", str(root / "absent.sock")], + env=env, capture_output=True, text=True, timeout=30, + ) + if "`fabro upgrade`" not in doctor.stdout or "--force" in doctor.stdout: + raise RuntimeError("incomplete installation did not explain how to finish the upgrade:\n" + doctor.stdout + doctor.stderr) + + def upgrade(*args, extra_env=None): + return subprocess.run( + [str(launcher), "upgrade", *args], + env={**env, **(extra_env or {})}, capture_output=True, text=True, timeout=120, + ) + + # No latest-release response is available: automatic repair must select the + # installed release, including a nightly, without consulting another channel. + result = upgrade("--dry-run") + if result.returncode or f"Would repair the fabro {version}" not in result.stderr: + raise RuntimeError("repair preview did not stay on the installed version:\n" + result.stdout + result.stderr) + if launcher.is_symlink() or list(install.iterdir()) != [launcher]: + raise RuntimeError("dry-run changed the incomplete installation") + + # Explicit selection must still take precedence over implicit repair. + for args, extra_env, expected in [ + (("--dry-run", "--json"), {}, version), + (("--dry-run", "--json", "--version", "999.0.0"), {}, "999.0.0"), + (("--dry-run", "--json", "--prerelease"), { + "FAKE_PRERELEASES": '[{"tag_name":"v999.0.1-nightly.0","draft":false}]', + }, "999.0.1-nightly.0"), + ]: + result = upgrade(*args, extra_env=extra_env) + if result.returncode or json.loads(result.stdout) != { + "previous_version": version, "installed_version": expected, "dry_run": True, + }: + raise RuntimeError("upgrade selected the wrong release:\n" + result.stdout + result.stderr) + + previous = None + for attempt in range(2): + result = upgrade() + if result.returncode: + raise RuntimeError("bundle upgrade failed:\n" + result.stdout + result.stderr) + current = launcher.resolve() + if not launcher.is_symlink() or current == previous: + raise RuntimeError("upgrade did not activate a new complete bundle") + for name in ("fabro", "sandbox-driver-host", "sandbox-driver-docker", "sandbox-driver-daytona"): + if (current.parent / name).read_bytes() != (source / name).read_bytes(): + raise RuntimeError(f"upgrade did not preserve {name}") + if previous: + old = previous.parent / name + if name == "sandbox-driver-daytona": + if old.exists(): + raise RuntimeError("repair modified the old incomplete bundle") + elif old.read_bytes() != (source / name).read_bytes(): + raise RuntimeError(f"upgrade changed the previous bundle's {name}") + previous = current + # Once complete, the same version is a no-op, even if selected through + # the default release lookup. No force is needed for either repair. + result = upgrade(extra_env={"FAKE_LATEST_STABLE": f"v{version}"}) + if result.returncode or f"Already on version {version}" not in result.stderr or launcher.resolve() != current: + raise RuntimeError("complete same-version install was not a no-op:\n" + result.stdout + result.stderr) + if attempt == 0: + # Also cover partial bundles left in managed version directories. + (current.parent / "sandbox-driver-daytona").unlink() + + # Explicit same-version selection also repairs without --force. Refusing a + # bad checksum must leave even an incomplete active installation untouched. + missing = previous.parent / "sandbox-driver-daytona" + missing.unlink() + + checksum.write_text("0" * 64 + "\n") + result = upgrade() + if result.returncode == 0 or "sha256 mismatch" not in result.stderr.lower(): + raise RuntimeError("updater accepted an invalid archive checksum:\n" + result.stdout + result.stderr) + if launcher.resolve() != previous: + raise RuntimeError("failed upgrade changed the active bundle") + checksum.write_text(f"{digest.hexdigest()} {archive.name}\n") + result = upgrade("--version", version) + if result.returncode or not (launcher.resolve().parent / missing.name).is_file(): + raise RuntimeError("explicit same-version repair failed:\n" + result.stdout + result.stderr) + + def upgrade_and_tamper_new_bundle(): + result = upgrade("--force", "--version", version) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + with (launcher.resolve().parent / "sandbox-driver-host").open("ab") as plugin: + plugin.write(b"\nchecksum smoke test\n") + + return launcher, upgrade_and_tamper_new_bundle + + +def main(): + source = Path(sys.argv[1]).resolve().parent + with tempfile.TemporaryDirectory(prefix="fabro-bundle-smoke-") as directory: + root = Path(directory) + bundle = root / "bundle" + bundle.mkdir() + for name in ("fabro", "sandbox-driver-host", "sandbox-driver-docker", "sandbox-driver-daytona"): + shutil.copy2(source / name, bundle / name) + workflow(bundle / "fabro", root / "valid") + launcher, upgrade_and_tamper = upgrade_bundle(bundle, root, sys.argv[2]) + # A running server must stay on its old bundle even if the newly + # activated bundle's plugin becomes corrupt. A fresh server must reject it. + workflow(launcher, root / "upgraded", after_first_run=upgrade_and_tamper) + workflow(launcher, root / "tampered", expect_tampered=True) + print("Release bundle installs cleanly, repairs missing executables, upgrades atomically, executes workflows, and rejects checksum mismatches.") + + +if __name__ == "__main__": + main() diff --git a/test/bin/install_test.sh b/test/bin/install_test.sh index d0d15e777..f048f0b02 100644 --- a/test/bin/install_test.sh +++ b/test/bin/install_test.sh @@ -39,6 +39,7 @@ target_triple() { } home_dir="$(mktemp -d)" +trap 'rm -rf "$home_dir"' EXIT fake_bin="$home_dir/fake-bin" gh_log="$home_dir/gh.log" install_dir="$home_dir/install" @@ -108,6 +109,21 @@ case "$1" in echo "fabro 9.9.9" INNER chmod +x "$bundle_dir/fabro" + case "${FAKE_BUNDLE_MODE:-legacy}" in + complete|partial|symlink) + for kind in host docker daytona; do + printf '%s\n' "${FAKE_PLUGIN_CONTENT:-first}" > "$bundle_dir/sandbox-driver-$kind" + chmod +x "$bundle_dir/sandbox-driver-$kind" + done + ;; + esac + case "${FAKE_BUNDLE_MODE:-legacy}" in + partial) rm "$bundle_dir/sandbox-driver-daytona" ;; + symlink) + rm "$bundle_dir/sandbox-driver-daytona" + ln -s fabro "$bundle_dir/sandbox-driver-daytona" + ;; + esac tar czf "$dest_dir/$asset" -C "$work_dir" "$(basename "$bundle_dir")" rm -rf "$work_dir" ;; @@ -137,3 +153,35 @@ grep -Fqx "api repos/fabro-sh/fabro/releases/latest --jq .tag_name" "$gh_log" \ || fail "install script should resolve the stable release tag explicitly" grep -Fq "release download v9.9.9 --repo fabro-sh/fabro --pattern $asset" "$gh_log" \ || fail "install script should download the resolved stable tag explicitly" + +install_bundle() { + HOME="$home_dir" \ + PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + FABRO_INSTALL_DIR="$install_dir" \ + FAKE_GH_LOG="$gh_log" \ + FAKE_BUNDLE_MODE="$1" \ + FAKE_PLUGIN_CONTENT="$2" \ + "$INSTALL_SCRIPT" >"$home_dir/install.log" 2>&1 +} + +install_bundle complete first || fail "complete bundle should install" +first_launcher="$(readlink "$install_dir/fabro")" +first_bundle="$install_dir/$(dirname "$first_launcher")" +for kind in host docker daytona; do + [[ "$(cat "$first_bundle/sandbox-driver-$kind")" == first ]] || fail "missing bundled $kind plugin" +done + +install_bundle complete second || fail "second bundle should install" +second_launcher="$(readlink "$install_dir/fabro")" +[[ "$first_launcher" != "$second_launcher" ]] || fail "upgrade should switch bundles" +for kind in host docker daytona; do + [[ "$(cat "$first_bundle/sandbox-driver-$kind")" == first ]] || fail "running server's plugin should remain unchanged" + [[ "$(cat "$install_dir/$(dirname "$second_launcher")/sandbox-driver-$kind")" == second ]] || fail "upgrade should install every plugin" +done + +for mode in partial symlink; do + if install_bundle "$mode" invalid; then fail "$mode bundle should be rejected"; fi + [[ "$(readlink "$install_dir/fabro")" == "$second_launcher" ]] || fail "failed install changed active bundle" +done + +printf 'Installer accepts complete bundles and preserves the active bundle on invalid input.\n'