fabro/lib/apps/fabro-cli/tests/it/workflow/plugin.rs
Bryan Helmkamp 030e653abf
Run plugin providers end to end and gate them in CI
Closes the sandbox-driver adoption: any provider a sandbox-driver plugin
executable serves can now host a fabro run, and fabro's own bundled
providers can be served the same way.

- `SandboxSpec::Plugin` builds a normalized driver spec from the
  environment (image or Dockerfile source, or a provider-managed
  directory; resources; network policy; labels; env) and lays fabro's
  repository checkout out inside the provider's working directory. The
  layout is recorded on the run through the new `workspace_layout` trait
  method.
- Plugin settings on a bundled kind (`[server.sandbox.providers.docker]
  path = ...`) serve that kind out of process through the driver's
  executable; the config layer no longer rejects them.
- `ProviderAccess` carries the server's provider settings and the vault's
  Daytona credentials to every reconnect: run resume, sandbox details,
  terminals, previews, and the worker's start path. The worker receives
  the settings through `StartServices`. No "plugin not wired" errors
  remain.
- The CLI worker requires GitHub credentials only when a repository will
  be cloned; a `none` target on a clone-based provider creates an empty
  workspace and needs none.
- fabro-db tracks its migrations directory so a new migration file
  recompiles the crate; the environment provider migration had been
  silently missing from stale builds. Environment store 500s now log
  their cause.
- The CLI workflow scenarios run against `host-plugin` (the driver's
  Host executable under the non-bundled `host` kind) and `docker-plugin`
  (the bundled `docker` kind served over stdio), each on an isolated
  server, printing the server log on failure. A live Daytona gate runs
  the native git clone over the JSON-RPC wire. A new CI job runs the
  plugin scenarios and the driver-backed Docker integration tests with
  the plugin executables built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 19:54:57 -06:00

222 lines
7.3 KiB
Rust

//! Sandbox providers served by sandbox-driver plugin executables, for the
//! workflow scenarios.
//!
//! The executables come from the `fabro-sandbox` package's `[[bin]]` targets,
//! which `cargo` places beside the `fabro` binary under test. A scenario
//! configured here runs against its own server so the plugin settings and the
//! environment it creates never leak into the shared session server.
#![expect(
clippy::disallowed_methods,
reason = "test setup reads the process environment for its opt-in gate and probes Docker synchronously"
)]
#![expect(
clippy::print_stderr,
reason = "a skipped scenario says why on the test's stderr"
)]
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use fabro_test::{TestContext, expect_reqwest_status};
use serde_json::json;
use crate::cmd::support::server_endpoint;
/// Set in CI so a missing executable or daemon fails the test instead of
/// skipping it.
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
const DOCKER_IMAGE: &str = "buildpack-deps:noble";
#[derive(Clone, Copy, Debug)]
pub(crate) enum Plugin {
/// The driver's Host executable under the non-bundled `host` kind.
Host,
/// The driver's Docker executable serving the bundled `docker` kind out
/// of process.
Docker,
}
impl Plugin {
fn kind(self) -> &'static str {
match self {
Self::Host => "host",
Self::Docker => "docker",
}
}
fn executable(self) -> &'static str {
match self {
Self::Host => "fabro-sandbox-host",
Self::Docker => "fabro-sandbox-docker",
}
}
/// The environment id the scenario selects with `--environment`.
fn environment(self) -> &'static str {
match self {
Self::Host => "host-plugin",
Self::Docker => "docker-plugin",
}
}
}
/// Point `context` at an isolated server that serves `plugin` and has an
/// environment for it. Returns the environment id, or `None` when the
/// prerequisites are missing and the test should skip.
pub(crate) fn configure(context: &mut TestContext, plugin: Plugin) -> Option<&'static str> {
let required = std::env::var_os(REQUIRE_ENV).is_some();
let Some(executable) = plugin_executable(plugin) else {
assert!(
!required,
"{REQUIRE_ENV} is set but the {} executable is not built",
plugin.executable()
);
eprintln!(
"skipping: {} is not built; run `cargo build -p fabro-sandbox --bins`",
plugin.executable()
);
return None;
};
if matches!(plugin, Plugin::Docker) && !docker_image_available() {
assert!(
!required,
"{REQUIRE_ENV} is set but no Docker daemon with {DOCKER_IMAGE} is available"
);
eprintln!("skipping: no Docker daemon with {DOCKER_IMAGE}");
return None;
}
let storage_dir = context.temp_dir.join("plugin-server-storage");
let registry = context.temp_dir.join("host-registry");
std::fs::create_dir_all(&registry).expect("registry dir should be created");
let settings = match plugin {
Plugin::Host => format!(
r#"[server.storage]
root = "{storage}"
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.host]
path = "{path}"
dev = true
inherit_env = ["PATH", "HOME"]
[server.sandbox.providers.host.env]
SANDBOX_DRIVER_HOST_REGISTRY = "{registry}"
"#,
storage = toml_path(&storage_dir),
path = toml_path(&executable),
registry = toml_path(&registry),
),
Plugin::Docker => format!(
r#"[server.storage]
root = "{storage}"
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.docker]
path = "{path}"
dev = true
inherit_env = ["PATH", "HOME", "DOCKER_HOST", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY"]
"#,
storage = toml_path(&storage_dir),
path = toml_path(&executable),
),
};
context.write_home(".fabro/settings.toml", settings);
context.isolated_server();
create_environment(&context.storage_dir, plugin);
Some(plugin.environment())
}
/// The plugin executable `cargo` built beside the `fabro` binary under test.
fn plugin_executable(plugin: Plugin) -> Option<PathBuf> {
let fabro = Path::new(env!("CARGO_BIN_EXE_fabro"));
let candidate = fabro.with_file_name(plugin.executable());
candidate.is_file().then_some(candidate)
}
fn docker_image_available() -> bool {
Command::new("docker")
.args(["image", "inspect", DOCKER_IMAGE])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn toml_path(path: &Path) -> String {
path.display().to_string().replace('\\', "/")
}
fn create_environment(storage_dir: &Path, plugin: Plugin) {
let body = json!({
"id": plugin.environment(),
"provider": plugin.kind(),
"image": {
"docker": match plugin {
Plugin::Host => serde_json::Value::Null,
Plugin::Docker => json!(DOCKER_IMAGE),
},
"dockerfile": null
},
"resources": { "cpu": null, "memory": null, "disk": null },
"network": { "mode": "allow_all", "allow": [] },
"lifecycle": { "preserve": false, "stop_on_terminal": true, "auto_stop": null },
"labels": {},
"env": {}
});
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build")
.block_on(async {
let (client, base_url) =
server_endpoint(storage_dir).expect("isolated server endpoint should exist");
let response = client
.post(format!("{base_url}/api/v1/environments"))
.json(&body)
.send()
.await
.expect("environment create request should send");
if response.status() != fabro_http::StatusCode::CREATED {
eprintln!("server log tail:\n{}", server_log_tail(storage_dir));
}
expect_reqwest_status(
response,
fabro_http::StatusCode::CREATED,
"POST /api/v1/environments",
)
.await;
});
}
/// Run a scenario; when it fails, print the isolated server's log first, since
/// the worker's stderr (and so a plugin's launch failure) lands only there
/// and the server root is removed when the context drops.
pub(crate) fn run_with_server_log(context: &TestContext, scenario: impl FnOnce()) {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(scenario));
if let Err(panic) = outcome {
eprintln!(
"server log tail:\n{}",
server_log_tail(&context.storage_dir)
);
std::panic::resume_unwind(panic);
}
}
/// The last lines of the isolated server's log, for a failure message.
pub(crate) fn server_log_tail(storage_dir: &Path) -> String {
let path = fabro_config::Storage::new(storage_dir)
.runtime_directory()
.log_path();
let Ok(contents) = std::fs::read_to_string(&path) else {
return format!("(no server log at {})", path.display());
};
let lines: Vec<&str> = contents.lines().collect();
let start = lines.len().saturating_sub(60);
lines[start..].join("\n")
}