Bundle verified sandbox executables and repair incomplete upgrades

This commit is contained in:
Scott Werner 2026-09-21 17:41:29 -04:00
parent 7518045203
commit daed85a0eb
24 changed files with 1204 additions and 62 deletions

View file

@ -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

View file

@ -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

2
Cargo.lock generated
View file

@ -2253,6 +2253,8 @@ dependencies = [
"fabro-options-metadata",
"fabro-util",
"quick-xml 0.36.2",
"serde",
"serde_json",
"tempfile",
"toml_edit",
"tracing-subscriber",

View file

@ -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

View file

@ -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 |
---

View file

@ -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

View file

@ -40,3 +40,36 @@ Replace `<version>` 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.

View file

@ -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

View file

@ -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

View file

@ -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(),

View file

@ -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,

View file

@ -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<CheckResult> {
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<CheckResult> {
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(&current_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(&current_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(&current_exe, &backup).context("failed to move current binary to backup")?;
if let Err(e) = fs::rename(&extracted_binary, &current_exe) {
// Restore from backup
if let Err(restore_err) = fs::rename(&backup, &current_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(&current_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."
)
);
}
}

View file

@ -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<PathBuf> {
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");
}
}

View file

@ -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<String> = 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);

View file

@ -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, @"

View file

@ -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

View file

@ -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<String>,
}
#[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<String> {
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)
}
})
}

View file

@ -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}"
)
}

View file

@ -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};

View file

@ -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<String>,
/// Use cargo-zigbuild for the plugin binaries (musl release targets).
#[arg(long)]
zigbuild: bool,
}
#[derive(Deserialize)]
struct Metadata {
packages: Vec<Package>,
target_directory: PathBuf,
}
#[derive(Deserialize)]
struct Package {
name: String,
source: Option<String>,
}
#[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<PathBuf> {
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<String> {
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"
);
}
}

View file

@ -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());
}

View file

@ -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),
}

297
test/bin/bundle_smoke.py Normal file
View file

@ -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()

View file

@ -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'