mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-14 04:12:08 +00:00
Bundle verified sandbox executables and repair incomplete upgrades
This commit is contained in:
parent
7518045203
commit
daed85a0eb
24 changed files with 1204 additions and 62 deletions
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
Cargo.lock
generated
|
|
@ -2253,6 +2253,8 @@ dependencies = [
|
|||
"fabro-options-metadata",
|
||||
"fabro-util",
|
||||
"quick-xml 0.36.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"toml_edit",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(¤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."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
233
lib/apps/fabro-cli/src/commands/upgrade/bundle.rs
Normal file
233
lib/apps/fabro-cli/src/commands/upgrade/bundle.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, @"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
222
lib/foundation/fabro-dev/src/commands/plugins.rs
Normal file
222
lib/foundation/fabro-dev/src/commands/plugins.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
297
test/bin/bundle_smoke.py
Normal 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()
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue