mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
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>
This commit is contained in:
parent
28d4242df0
commit
030e653abf
38 changed files with 1449 additions and 356 deletions
33
.github/workflows/rust.yml
vendored
33
.github/workflows/rust.yml
vendored
|
|
@ -133,6 +133,39 @@ jobs:
|
|||
# strict mode, which fails (rather than skips) live tests without keys.
|
||||
- run: cargo nextest run --locked --workspace --status-level slow --profile ci --run-ignored only -E 'package(fabro-agent) + package(fabro-llm) + package(twin-openai)'
|
||||
|
||||
sandbox-plugins:
|
||||
name: Sandbox plugins (stdio)
|
||||
runs-on: ubuntu-24.04-x86-32-cores
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
# The plugin scenarios skip when an executable or daemon is missing;
|
||||
# in CI a skip is a failure.
|
||||
FABRO_REQUIRE_SANDBOX_PLUGINS: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
toolchain: 1.97.1
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
with:
|
||||
cache-on-failure: true
|
||||
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
|
||||
- run: docker pull buildpack-deps:noble
|
||||
# The driver's Host and Docker executables fabro ships as
|
||||
# `fabro-sandbox-<kind>`; the CLI scenarios launch them over stdio.
|
||||
- run: cargo build --locked -p fabro-sandbox --bins
|
||||
# Host and Docker served as plugins through the workflow scenarios. The
|
||||
# scenarios are e2e tests (ignored by default); the key-free ones run
|
||||
# here, the LLM-backed ones self-skip without credentials.
|
||||
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/host_plugin_|docker_plugin_/)'
|
||||
# The driver-backed Docker integration tests and the stdio plugin proof.
|
||||
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-sandbox --test docker_streaming --test plugin_provider
|
||||
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-agent --test it -E 'test(docker_shell)'
|
||||
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-workflow --test it -E 'test(asset_collection_docker_sandbox)'
|
||||
|
||||
test-macos:
|
||||
name: Test (macOS)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
|
|
|||
|
|
@ -201,8 +201,12 @@ enabled = true
|
|||
Any other key names a [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugin:
|
||||
an executable that speaks the sandbox-driver JSON-RPC protocol on stdin and stdout. The kind must
|
||||
be lowercase ASCII letters, digits, and interior hyphens. The plugin starts with a scrubbed
|
||||
environment: only `env` and the ambient variables listed in `inherit_env` reach it. Bundled
|
||||
providers reject these plugin keys.
|
||||
environment: only `env` and the ambient variables listed in `inherit_env` reach it.
|
||||
|
||||
A bundled provider accepts the same plugin keys. Setting any of them runs that provider out of
|
||||
process through the driver's executable for the kind (`fabro-sandbox-docker` for `docker`), which
|
||||
isolates the server from provider crashes at the cost of a process per connection. Without them
|
||||
the bundled provider links in-process.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[server.sandbox.providers.e2b]
|
||||
|
|
|
|||
|
|
@ -20,10 +20,7 @@ use fabro_server::run_tool_manifest;
|
|||
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
|
||||
use fabro_tool::fabro_client::ClientBackend;
|
||||
use fabro_types::settings::run::{RunMode, RunNamespace};
|
||||
use fabro_types::{
|
||||
ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId,
|
||||
WorkflowSettings,
|
||||
};
|
||||
use fabro_types::{ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId};
|
||||
use fabro_vault::{SecretStore, Vault};
|
||||
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
|
||||
use fabro_workflow::event::{Emitter, RunEventSink};
|
||||
|
|
@ -139,8 +136,11 @@ pub(crate) async fn execute(
|
|||
let vault = load_worker_vault(&storage_dir).await?;
|
||||
let github_app = {
|
||||
let vault_guard = vault.read().await;
|
||||
maybe_build_github_credentials(&run_spec.settings, &vault_guard)?
|
||||
maybe_build_github_credentials(run_spec, &vault_guard)?
|
||||
};
|
||||
let sandbox_providers = ServerSettingsBuilder::load_default()
|
||||
.map(|settings| settings.server.sandbox.providers)
|
||||
.unwrap_or_default();
|
||||
let services = StartServices {
|
||||
run_id,
|
||||
cancel_token: cancel_token.clone(),
|
||||
|
|
@ -169,6 +169,7 @@ pub(crate) async fn execute(
|
|||
.resolve_integration()
|
||||
.context("failed to resolve github integration")?,
|
||||
vault,
|
||||
sandbox_providers,
|
||||
catalog,
|
||||
on_node: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1100,10 +1101,13 @@ fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
|
|||
}
|
||||
|
||||
fn maybe_build_github_credentials(
|
||||
settings: &WorkflowSettings,
|
||||
run_spec: &fabro_types::RunSpec,
|
||||
vault: &fabro_vault::Vault,
|
||||
) -> Result<Option<fabro_github::GitHubCredentials>> {
|
||||
let resolved_run = &settings.run;
|
||||
let resolved_run = &run_spec.settings.run;
|
||||
let has_repo_origin = run_spec
|
||||
.repo_origin_url()
|
||||
.is_some_and(|origin| !origin.trim().is_empty());
|
||||
let resolved_server = ServerSettingsBuilder::load_default().ok();
|
||||
let server_ns = resolved_server.as_ref().map(|s| &s.server);
|
||||
let strategy = server_ns
|
||||
|
|
@ -1112,7 +1116,7 @@ fn maybe_build_github_credentials(
|
|||
let app_id = server_ns.and_then(|server| server.integrations.github.app_id.clone());
|
||||
let app_slug = server_ns.and_then(|server| server.integrations.github.slug.clone());
|
||||
|
||||
if requires_github_credentials(resolved_run) {
|
||||
if requires_github_credentials(resolved_run, has_repo_origin) {
|
||||
return build_github_credentials(strategy, app_id.as_deref(), app_slug.as_deref(), vault);
|
||||
}
|
||||
|
||||
|
|
@ -1133,14 +1137,17 @@ fn maybe_build_github_credentials(
|
|||
}
|
||||
|
||||
/// Hard-gate for the CLI worker path: a run-level token is requested, or
|
||||
/// a clone-based sandbox in non-dry-run mode will need credentials to
|
||||
/// pull the repository. Pull-request-driven credential acquisition is
|
||||
/// handled separately by the caller as a soft fallback.
|
||||
fn requires_github_credentials(run: &RunNamespace) -> bool {
|
||||
/// a clone-based sandbox in non-dry-run mode will clone a repository and
|
||||
/// needs credentials to pull it. A run without a repository origin creates
|
||||
/// an empty workspace and needs none. Pull-request-driven credential
|
||||
/// acquisition is handled separately by the caller as a soft fallback.
|
||||
fn requires_github_credentials(run: &RunNamespace, has_repo_origin: bool) -> bool {
|
||||
if run.integrations.github.is_token_requested() {
|
||||
return true;
|
||||
}
|
||||
run.execution.mode != RunMode::DryRun && run.environment.provider.clones_workspace()
|
||||
run.execution.mode != RunMode::DryRun
|
||||
&& run.environment.provider.clones_workspace()
|
||||
&& has_repo_origin
|
||||
}
|
||||
|
||||
fn install_signal_handlers(
|
||||
|
|
@ -1776,28 +1783,38 @@ mod tests {
|
|||
// Even with local sandbox + dry-run, non-empty permissions
|
||||
// force credential acquisition.
|
||||
let run = run_with(permissions, "local", RunMode::DryRun);
|
||||
assert!(requires_github_credentials(&run));
|
||||
assert!(requires_github_credentials(&run, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_github_credentials_for_clone_based_provider() {
|
||||
fn requires_github_credentials_for_clone_based_provider_with_an_origin() {
|
||||
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
|
||||
assert!(requires_github_credentials(&run));
|
||||
assert!(requires_github_credentials(&run, true));
|
||||
|
||||
let daytona = run_with(HashMap::new(), "daytona", RunMode::Normal);
|
||||
assert!(requires_github_credentials(&daytona));
|
||||
assert!(requires_github_credentials(&daytona, true));
|
||||
|
||||
let plugin = run_with(HashMap::new(), "host", RunMode::Normal);
|
||||
assert!(requires_github_credentials(&plugin, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_require_github_credentials_without_a_repository_origin() {
|
||||
// A `none` target creates an empty workspace; nothing is cloned.
|
||||
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
|
||||
assert!(!requires_github_credentials(&run, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_require_github_credentials_for_local_clean_run() {
|
||||
let run = run_with(HashMap::new(), "local", RunMode::Normal);
|
||||
assert!(!requires_github_credentials(&run));
|
||||
assert!(!requires_github_credentials(&run, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_require_github_credentials_for_clone_provider_in_dry_run() {
|
||||
let run = run_with(HashMap::new(), "docker", RunMode::DryRun);
|
||||
assert!(!requires_github_credentials(&run));
|
||||
assert!(!requires_github_credentials(&run, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use fabro_test::test_context;
|
||||
use fabro_test::TestContext;
|
||||
|
||||
use super::{
|
||||
completed_nodes, find_run_dir, fixture, has_event, read_conclusion, sandbox_tests, timeout_for,
|
||||
|
|
@ -6,9 +6,7 @@ use super::{
|
|||
|
||||
sandbox_tests!(agent_linear, keys = ["ANTHROPIC_API_KEY"]);
|
||||
|
||||
fn scenario_agent_linear(sandbox: &str) {
|
||||
let context = test_context!();
|
||||
|
||||
fn scenario_agent_linear(context: &TestContext, sandbox: &str) {
|
||||
context
|
||||
.run_cmd()
|
||||
.args([
|
||||
|
|
@ -23,7 +21,7 @@ fn scenario_agent_linear(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context);
|
||||
let run_dir = find_run_dir(context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
|
||||
)]
|
||||
|
||||
use fabro_test::test_context;
|
||||
use fabro_test::TestContext;
|
||||
|
||||
use super::{
|
||||
completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for,
|
||||
|
|
@ -12,9 +12,7 @@ use super::{
|
|||
|
||||
sandbox_tests!(command_agent_mixed, keys = ["ANTHROPIC_API_KEY"]);
|
||||
|
||||
fn scenario_command_agent_mixed(sandbox: &str) {
|
||||
let context = test_context!();
|
||||
|
||||
fn scenario_command_agent_mixed(context: &TestContext, sandbox: &str) {
|
||||
context
|
||||
.run_cmd()
|
||||
.args([
|
||||
|
|
@ -29,7 +27,7 @@ fn scenario_command_agent_mixed(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context);
|
||||
let run_dir = find_run_dir(context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
|
||||
|
||||
|
|
@ -47,7 +45,7 @@ fn scenario_command_agent_mixed(sandbox: &str) {
|
|||
"verify should be completed"
|
||||
);
|
||||
|
||||
let export_dir = dump_export(&context, &run_id_for(&run_dir));
|
||||
let export_dir = dump_export(context, &run_id_for(&run_dir));
|
||||
let stdout =
|
||||
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("output.log"))
|
||||
.expect("verify output.log should exist");
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
|
||||
)]
|
||||
|
||||
use fabro_test::test_context;
|
||||
use fabro_test::TestContext;
|
||||
|
||||
use super::{
|
||||
completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for,
|
||||
|
|
@ -12,9 +12,7 @@ use super::{
|
|||
|
||||
sandbox_tests!(command_pipeline);
|
||||
|
||||
fn scenario_command_pipeline(sandbox: &str) {
|
||||
let context = test_context!();
|
||||
|
||||
fn scenario_command_pipeline(context: &TestContext, sandbox: &str) {
|
||||
context
|
||||
.validate()
|
||||
.arg(fixture("command_pipeline.fabro"))
|
||||
|
|
@ -29,7 +27,7 @@ fn scenario_command_pipeline(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context);
|
||||
let run_dir = find_run_dir(context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(
|
||||
conclusion["status"].as_str(),
|
||||
|
|
@ -47,7 +45,7 @@ fn scenario_command_pipeline(sandbox: &str) {
|
|||
"step2 should be completed"
|
||||
);
|
||||
|
||||
let export_dir = dump_export(&context, &run_id_for(&run_dir));
|
||||
let export_dir = dump_export(context, &run_id_for(&run_dir));
|
||||
let stdout1 =
|
||||
std::fs::read_to_string(stage_dump_dir(&export_dir, "step1@1").join("output.log"))
|
||||
.expect("step1 output.log should exist");
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
use fabro_test::test_context;
|
||||
use fabro_test::TestContext;
|
||||
|
||||
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
|
||||
|
||||
sandbox_tests!(command_routing);
|
||||
|
||||
fn scenario_command_routing(sandbox: &str) {
|
||||
let context = test_context!();
|
||||
fn scenario_command_routing(context: &TestContext, sandbox: &str) {
|
||||
let workflow = fixture("command_routing.fabro");
|
||||
|
||||
context.validate().arg(&workflow).assert().success();
|
||||
|
|
@ -18,7 +17,7 @@ fn scenario_command_routing(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context);
|
||||
let run_dir = find_run_dir(context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
use fabro_test::test_context;
|
||||
use fabro_test::TestContext;
|
||||
|
||||
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
|
||||
|
||||
sandbox_tests!(conditional_branching);
|
||||
|
||||
fn scenario_conditional_branching(sandbox: &str) {
|
||||
let context = test_context!();
|
||||
|
||||
fn scenario_conditional_branching(context: &TestContext, sandbox: &str) {
|
||||
context
|
||||
.run_cmd()
|
||||
.args(["--auto-approve", "--environment", sandbox])
|
||||
|
|
@ -15,7 +13,7 @@ fn scenario_conditional_branching(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context);
|
||||
let run_dir = find_run_dir(context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
|
||||
)]
|
||||
|
||||
use fabro_test::test_context;
|
||||
use fabro_test::TestContext;
|
||||
|
||||
use super::{
|
||||
completed_nodes, dump_export, find_run_dir, fixture, has_event, read_conclusion, read_run_spec,
|
||||
|
|
@ -12,9 +12,7 @@ use super::{
|
|||
|
||||
sandbox_tests!(full_stack, keys = ["ANTHROPIC_API_KEY"]);
|
||||
|
||||
fn scenario_full_stack(sandbox: &str) {
|
||||
let context = test_context!();
|
||||
|
||||
fn scenario_full_stack(context: &TestContext, sandbox: &str) {
|
||||
context
|
||||
.run_cmd()
|
||||
.args([
|
||||
|
|
@ -29,7 +27,7 @@ fn scenario_full_stack(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context);
|
||||
let run_dir = find_run_dir(context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(
|
||||
conclusion["status"].as_str(),
|
||||
|
|
@ -72,7 +70,7 @@ fn scenario_full_stack(sandbox: &str) {
|
|||
}
|
||||
|
||||
// Verify node stdout should contain PASS
|
||||
let export_dir = dump_export(&context, &run_id_for(&run_dir));
|
||||
let export_dir = dump_export(context, &run_id_for(&run_dir));
|
||||
let stdout =
|
||||
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("output.log"))
|
||||
.expect("verify output.log should exist");
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
use fabro_test::test_context;
|
||||
use fabro_test::TestContext;
|
||||
|
||||
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
|
||||
|
||||
sandbox_tests!(human_gate, keys = ["ANTHROPIC_API_KEY"]);
|
||||
|
||||
fn scenario_human_gate(sandbox: &str) {
|
||||
let context = test_context!();
|
||||
|
||||
fn scenario_human_gate(context: &TestContext, sandbox: &str) {
|
||||
context
|
||||
.run_cmd()
|
||||
.args([
|
||||
|
|
@ -21,7 +19,7 @@ fn scenario_human_gate(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context);
|
||||
let run_dir = find_run_dir(context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ mod dry_run_examples;
|
|||
mod full_stack;
|
||||
mod hooks;
|
||||
mod human_gate;
|
||||
pub(super) mod plugin;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
|
@ -167,6 +168,18 @@ fn run_events(run_dir: &Path) -> Vec<EventEnvelope> {
|
|||
crate::support::parse_event_envelopes(&response)
|
||||
}
|
||||
|
||||
/// Runs a scenario against every sandbox provider fabro supports:
|
||||
///
|
||||
/// - `local`: the bundled Host provider in-process.
|
||||
/// - `daytona`: the bundled Daytona provider, live credentials required.
|
||||
/// - `host-plugin`: the driver's Host executable over stdio under the
|
||||
/// non-bundled `host` kind, a clone-based managed workspace.
|
||||
/// - `docker-plugin`: the bundled `docker` kind served out of process by the
|
||||
/// driver's Docker executable.
|
||||
///
|
||||
/// The plugin variants need the executables `cargo` builds for
|
||||
/// `fabro-sandbox`; without them (or without a Docker daemon) they skip,
|
||||
/// unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set, as CI sets it.
|
||||
macro_rules! sandbox_tests {
|
||||
($name:ident) => {
|
||||
sandbox_tests!($name, keys = []);
|
||||
|
|
@ -175,12 +188,36 @@ macro_rules! sandbox_tests {
|
|||
paste::paste! {
|
||||
#[fabro_macros::e2e_test($(live($key)),*)]
|
||||
fn [<local_ $name>]() {
|
||||
[<scenario_ $name>]("local");
|
||||
[<scenario_ $name>](&fabro_test::test_context!(), "local");
|
||||
}
|
||||
|
||||
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY") $(, live($key))*)]
|
||||
fn [<daytona_ $name>]() {
|
||||
[<scenario_ $name>]("daytona");
|
||||
[<scenario_ $name>](&fabro_test::test_context!(), "daytona");
|
||||
}
|
||||
|
||||
#[fabro_macros::e2e_test($(live($key)),*)]
|
||||
fn [<host_plugin_ $name>]() {
|
||||
let mut context = fabro_test::test_context!();
|
||||
if let Some(environment) =
|
||||
$crate::workflow::plugin::configure(&mut context, $crate::workflow::plugin::Plugin::Host)
|
||||
{
|
||||
$crate::workflow::plugin::run_with_server_log(&context, || {
|
||||
[<scenario_ $name>](&context, environment);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[fabro_macros::e2e_test($(live($key)),*)]
|
||||
fn [<docker_plugin_ $name>]() {
|
||||
let mut context = fabro_test::test_context!();
|
||||
if let Some(environment) =
|
||||
$crate::workflow::plugin::configure(&mut context, $crate::workflow::plugin::Plugin::Docker)
|
||||
{
|
||||
$crate::workflow::plugin::run_with_server_log(&context, || {
|
||||
[<scenario_ $name>](&context, environment);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -190,6 +227,7 @@ pub(super) use sandbox_tests;
|
|||
pub(super) fn timeout_for(sandbox: &str) -> Duration {
|
||||
match sandbox {
|
||||
"daytona" => Duration::from_mins(10),
|
||||
"docker-plugin" => Duration::from_mins(5),
|
||||
_ => Duration::from_mins(3),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
222
lib/apps/fabro-cli/tests/it/workflow/plugin.rs
Normal file
222
lib/apps/fabro-cli/tests/it/workflow/plugin.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! 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(®istry).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(®istry),
|
||||
),
|
||||
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")
|
||||
}
|
||||
|
|
@ -1208,11 +1208,11 @@ async fn reconnect_run_sandbox(
|
|||
.and_then(fabro_types::RunSandbox::instance)
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Run sandbox was not created."))?;
|
||||
let daytona = state
|
||||
.vault_daytona_credentials()
|
||||
let access = state
|
||||
.provider_access()
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
|
||||
let sandbox = reconnect_for_run(&record, daytona, Some(*run_id))
|
||||
let sandbox = reconnect_for_run(&record, &access, Some(*run_id))
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::CONFLICT, err.to_string()))?;
|
||||
sandbox
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ use fabro_sandbox::from_environment::{
|
|||
daytona_config_from_environment, docker_config_from_environment,
|
||||
local_working_directory_from_environment,
|
||||
};
|
||||
use fabro_sandbox::plugin::plugin_options_from_environment;
|
||||
use fabro_sandbox::redact::redact_auth_url;
|
||||
use fabro_sandbox::{DaytonaCredentials, DockerSandboxOptions, Sandbox, SandboxSpec};
|
||||
use fabro_sandbox::{DockerSandboxOptions, ProviderAccess, Sandbox, SandboxSpec};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::ModelRef;
|
||||
use fabro_types::settings::cli::OutputVerbosity;
|
||||
|
|
@ -484,14 +485,14 @@ async fn build_preflight_report(
|
|||
None
|
||||
};
|
||||
|
||||
let daytona = state.vault_daytona_credentials().await?;
|
||||
let access = state.provider_access().await?;
|
||||
let sandbox_ok = run_sandbox_check(
|
||||
&mut checks,
|
||||
&sandbox_provider,
|
||||
prepared,
|
||||
&resolved_run,
|
||||
github_app.clone(),
|
||||
daytona,
|
||||
&access,
|
||||
)
|
||||
.await;
|
||||
let repository_access_ok = run_repository_access_check(
|
||||
|
|
@ -919,7 +920,7 @@ fn preflight_sandbox_spec(
|
|||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
access: &ProviderAccess,
|
||||
) -> std::result::Result<SandboxSpec, fabro_sandbox::Error> {
|
||||
let clone_origin_url = prepared
|
||||
.git
|
||||
|
|
@ -959,13 +960,44 @@ fn preflight_sandbox_spec(
|
|||
clone_branch,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
credentials: daytona,
|
||||
credentials: access.daytona.clone(),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(fabro_sandbox::Error::message(format!(
|
||||
"sandbox provider `{sandbox_provider}` is not bundled; plugin providers are constructed by the server"
|
||||
)));
|
||||
let settings = access.settings_for(sandbox_provider).ok_or_else(|| {
|
||||
fabro_sandbox::Error::message(format!(
|
||||
"sandbox provider `{sandbox_provider}` is not configured; add [server.sandbox.providers.{sandbox_provider}] to settings.toml"
|
||||
))
|
||||
})?;
|
||||
// No vault is available on this path, so a `{{ secrets.* }}` value
|
||||
// keeps its source form, as the Docker preflight does.
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "preflight has no vault; an unresolved secret token is carried in source form"
|
||||
)]
|
||||
let env = resolved_run
|
||||
.environment
|
||||
.env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.as_source()))
|
||||
.collect();
|
||||
let mut options = plugin_options_from_environment(
|
||||
&resolved_run.environment,
|
||||
&resolved_run.clone,
|
||||
env,
|
||||
);
|
||||
options.skip_clone = true;
|
||||
SandboxSpec::Plugin {
|
||||
kind: sandbox_provider.clone(),
|
||||
settings: Box::new(settings),
|
||||
options: Box::new(options),
|
||||
github_app,
|
||||
run_id: None,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag: None,
|
||||
clone_commit_sha: None,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -976,14 +1008,14 @@ async fn run_sandbox_check(
|
|||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
access: &ProviderAccess,
|
||||
) -> bool {
|
||||
let spec = match preflight_sandbox_spec(
|
||||
sandbox_provider,
|
||||
prepared,
|
||||
resolved_run,
|
||||
github_app.clone(),
|
||||
daytona,
|
||||
access,
|
||||
) {
|
||||
Ok(spec) => spec,
|
||||
Err(err) => {
|
||||
|
|
@ -2242,7 +2274,7 @@ provider = "local"
|
|||
&prepared,
|
||||
&resolved,
|
||||
None,
|
||||
None,
|
||||
&ProviderAccess::default(),
|
||||
);
|
||||
|
||||
match spec {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ use fabro_model::catalog::LlmCatalogSettings;
|
|||
use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderId};
|
||||
use fabro_redact::redact_jsonl_line;
|
||||
use fabro_sandbox::details::sandbox_details;
|
||||
use fabro_sandbox::driver::{DaytonaCredentials, ProviderConnectOptions};
|
||||
use fabro_sandbox::driver::{DaytonaCredentials, ProviderAccess, ProviderConnectOptions};
|
||||
use fabro_sandbox::reconnect::reconnect_for_run;
|
||||
use fabro_sandbox::{
|
||||
DriverInventoryProvider, LocalSandboxProvider, Sandbox, SandboxProvider,
|
||||
|
|
@ -1487,14 +1487,17 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Daytona credentials from the vault, `None` when no key is stored.
|
||||
pub(crate) async fn vault_daytona_credentials(
|
||||
&self,
|
||||
) -> Result<Option<DaytonaCredentials>, SecretStoreError> {
|
||||
Ok(self
|
||||
.vault_secret(EnvVars::DAYTONA_API_KEY)
|
||||
.await?
|
||||
.map(|api_key| self.daytona_credentials(api_key)))
|
||||
/// Everything a reconnect needs to reach a run's provider: the server's
|
||||
/// provider settings and the Daytona credentials from the vault (`None`
|
||||
/// when no key is stored).
|
||||
pub(crate) async fn provider_access(&self) -> Result<ProviderAccess, SecretStoreError> {
|
||||
Ok(ProviderAccess {
|
||||
providers: self.server_settings().server.sandbox.providers.clone(),
|
||||
daytona: self
|
||||
.vault_secret(EnvVars::DAYTONA_API_KEY)
|
||||
.await?
|
||||
.map(|api_key| self.daytona_credentials(api_key)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn check_daytona_api_key(
|
||||
|
|
@ -2790,11 +2793,11 @@ async fn delete_run_sandbox_resource(
|
|||
}));
|
||||
}
|
||||
|
||||
let daytona = state
|
||||
.vault_daytona_credentials()
|
||||
let access = state
|
||||
.provider_access()
|
||||
.await
|
||||
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
|
||||
let sandbox = match reconnect_for_run(&record, daytona, Some(id)).await {
|
||||
let sandbox = match reconnect_for_run(&record, &access, Some(id)).await {
|
||||
Ok(sandbox) => sandbox,
|
||||
Err(err) if force || delete_started => {
|
||||
tracing::warn!(
|
||||
|
|
@ -4202,6 +4205,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
|
|||
github_app,
|
||||
github_integration,
|
||||
vault: Arc::new(AsyncRwLock::new(vault.into_vault())),
|
||||
sandbox_providers: state.server_settings().server.sandbox.providers.clone(),
|
||||
catalog: state.catalog(),
|
||||
on_node: None,
|
||||
registry_override,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use fabro_types::settings::run::{
|
|||
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
|
||||
EnvironmentNetworkSettings, EnvironmentResourcesSettings, EnvironmentSettings,
|
||||
};
|
||||
use fabro_util::error::{collect_chain, render_with_causes};
|
||||
use serde::de::IgnoredAny;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -267,10 +268,17 @@ impl From<EnvironmentStoreError> for ApiError {
|
|||
| EnvironmentStoreError::JsonDecode { .. }
|
||||
| EnvironmentStoreError::Db { .. }
|
||||
| EnvironmentStoreError::RowCountOverflow { .. }
|
||||
| EnvironmentStoreError::Io { .. } => Self::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"environment store operation failed",
|
||||
),
|
||||
| EnvironmentStoreError::Io { .. } => {
|
||||
// The response hides the cause; the log keeps it.
|
||||
tracing::error!(
|
||||
error = %render_with_causes(&err.to_string(), &collect_chain(&err)),
|
||||
"environment store operation failed"
|
||||
);
|
||||
Self::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"environment store operation failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ use std::sync::Arc;
|
|||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use fabro_sandbox::{DriverSandbox, TerminalSize, open_terminal_for_run, reconnect_driver_for_run};
|
||||
use fabro_sandbox::{
|
||||
DriverSandbox, ProviderAccess, TerminalSize, open_terminal_for_run, reconnect_driver_for_run,
|
||||
};
|
||||
use fabro_types::{
|
||||
RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta,
|
||||
};
|
||||
|
|
@ -13,9 +15,9 @@ use futures_util::FutureExt;
|
|||
use futures_util::future::BoxFuture;
|
||||
|
||||
use super::super::{
|
||||
ApiError, AppState, Bytes, DaytonaCredentials, HeaderMap, IntoResponse, Json, NamedTempFile,
|
||||
Path, PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId,
|
||||
Sandbox, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
|
||||
ApiError, AppState, Bytes, HeaderMap, IntoResponse, Json, NamedTempFile, Path,
|
||||
PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId, Sandbox,
|
||||
SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
|
||||
SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State, StatusCode,
|
||||
VncPreviewResponse, collect_causes, fs, get, octet_stream_response, parse_run_id_path, post,
|
||||
reject_if_archived, render_with_causes, sandbox_details,
|
||||
|
|
@ -98,11 +100,11 @@ async fn retrieve_run_sandbox(
|
|||
Ok(record) => record,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let daytona = match load_daytona_credentials(&state).await {
|
||||
let access = match load_provider_access(&state).await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return response,
|
||||
};
|
||||
match sandbox_details(&record, daytona, Some(id)).await {
|
||||
match sandbox_details(&record, &access, Some(id)).await {
|
||||
Ok(details) => Json::<SandboxDetails>(details).into_response(),
|
||||
Err(err) => {
|
||||
let detail = format!("{err:#}");
|
||||
|
|
@ -219,7 +221,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: Run
|
|||
return;
|
||||
}
|
||||
};
|
||||
let daytona = match load_daytona_credentials(&state).await {
|
||||
let access = match load_provider_access(&state).await {
|
||||
Ok(value) => value,
|
||||
Err(response) => {
|
||||
let _ = socket
|
||||
|
|
@ -233,7 +235,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: Run
|
|||
}
|
||||
};
|
||||
let session =
|
||||
match open_terminal_for_run(&record, daytona, Some(id), TerminalSize::default()).await {
|
||||
match open_terminal_for_run(&record, &access, Some(id), TerminalSize::default()).await {
|
||||
Ok(session) => session,
|
||||
Err(err) => {
|
||||
let _ = socket
|
||||
|
|
@ -892,8 +894,8 @@ async fn reconnect_driver_sandbox_instance(
|
|||
run_id: &RunId,
|
||||
record: &RunSandboxInstance,
|
||||
) -> Result<DriverSandbox, Response> {
|
||||
let daytona = load_daytona_credentials(state).await?;
|
||||
let sandbox = reconnect_driver_for_run(record, daytona, Some(*run_id), None)
|
||||
let access = load_provider_access(state).await?;
|
||||
let sandbox = reconnect_driver_for_run(record, &access, Some(*run_id), None)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref()));
|
||||
|
|
@ -905,10 +907,8 @@ async fn reconnect_driver_sandbox_instance(
|
|||
Ok(sandbox)
|
||||
}
|
||||
|
||||
async fn load_daytona_credentials(
|
||||
state: &AppState,
|
||||
) -> Result<Option<DaytonaCredentials>, Response> {
|
||||
state.vault_daytona_credentials().await.map_err(|err| {
|
||||
async fn load_provider_access(state: &AppState) -> Result<ProviderAccess, Response> {
|
||||
state.provider_access().await.map_err(|err| {
|
||||
tracing::error!(error = ?err, "Loading Daytona API key failed");
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
|
|
|||
|
|
@ -714,11 +714,11 @@ async fn build_agent_session(
|
|||
let sandbox_instance = sandbox_record.instance().ok_or_else(|| {
|
||||
AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!("run sandbox was not created"))
|
||||
})?;
|
||||
let daytona = state
|
||||
.vault_daytona_credentials()
|
||||
let access = state
|
||||
.provider_access()
|
||||
.await
|
||||
.map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))?;
|
||||
let sandbox = reconnect_for_run(sandbox_instance, daytona, Some(run_id))
|
||||
let sandbox = reconnect_for_run(sandbox_instance, &access, Some(run_id))
|
||||
.await
|
||||
.map_err(AskFabroBuildError::SandboxUnavailable)?;
|
||||
sandbox
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ pub use crate::config::{
|
|||
pub use crate::driver::DaytonaCredentials;
|
||||
use crate::driver::{ProviderConnectOptions, connect_provider};
|
||||
use crate::driver_sandbox::{
|
||||
CreatePlan, DriverSandbox, PreparedCreate, RepoWorkspace, WorkspaceLayout,
|
||||
CreatePlan, DriverSandbox, LayoutSource, PreparedCreate, RepoWorkspace, WorkspaceLayout,
|
||||
};
|
||||
use crate::managed_labels;
|
||||
use crate::sandbox::SandboxEvent;
|
||||
|
|
@ -512,7 +512,7 @@ pub async fn daytona_sandbox(
|
|||
credentials: &DaytonaCredentials,
|
||||
) -> crate::Result<DriverSandbox> {
|
||||
let workspace = RepoWorkspace::plan(
|
||||
layout(),
|
||||
LayoutSource::Fixed(layout()),
|
||||
config.skip_clone,
|
||||
clone_origin_url.as_deref(),
|
||||
clone_branch.as_deref(),
|
||||
|
|
@ -571,8 +571,12 @@ pub async fn attach_daytona(
|
|||
&status.labels,
|
||||
run_id.as_ref(),
|
||||
)?;
|
||||
let workspace =
|
||||
RepoWorkspace::attached(layout(), repo_cloned, working_directory, clone_origin_url);
|
||||
let workspace = RepoWorkspace::attached(
|
||||
LayoutSource::Fixed(layout()),
|
||||
repo_cloned,
|
||||
working_directory,
|
||||
clone_origin_url,
|
||||
);
|
||||
let sandbox = DriverSandbox::attached(SandboxProviderKind::DAYTONA, handle, workspace);
|
||||
if let Some(snapshot) = status.source {
|
||||
sandbox.set_snapshot(snapshot);
|
||||
|
|
@ -869,3 +873,119 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The git clone contract over the plugin wire against live Daytona.
|
||||
///
|
||||
/// Host and Docker derive their git facet from `Exec`, so only Daytona
|
||||
/// exercises the driver's native clone through the JSON-RPC protocol. The
|
||||
/// provider is served over an in-process duplex pipe exactly as a plugin
|
||||
/// executable would serve it on stdio.
|
||||
#[cfg(test)]
|
||||
mod wire_gate {
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use sandbox_driver::{SandboxProvider, SandboxSource, SandboxSpec as DriverSpec};
|
||||
use sandbox_driver_protocol::{PluginProvider, serve};
|
||||
use tokio::io::{duplex, split};
|
||||
|
||||
use super::*;
|
||||
use crate::Sandbox as _;
|
||||
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace};
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the live gate takes Daytona credentials from the developer's environment"
|
||||
)]
|
||||
fn live_credentials() -> Option<DaytonaCredentials> {
|
||||
let api_key = std::env::var(EnvVars::DAYTONA_API_KEY).ok()?;
|
||||
Some(DaytonaCredentials {
|
||||
api_key,
|
||||
api_url: std::env::var(EnvVars::DAYTONA_API_URL)
|
||||
.or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL))
|
||||
.ok(),
|
||||
organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(),
|
||||
target: None,
|
||||
http_client: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
||||
async fn native_clone_over_the_wire_lays_out_the_repository() {
|
||||
let credentials = live_credentials().expect("DAYTONA_API_KEY must be set");
|
||||
let in_process = connect(&credentials).await.expect("connect to Daytona");
|
||||
|
||||
let (host_side, plugin_side) = duplex(1024 * 1024);
|
||||
let (host_read, host_write) = split(host_side);
|
||||
let (plugin_read, plugin_write) = split(plugin_side);
|
||||
tokio::spawn(serve(Arc::clone(&in_process), plugin_read, plugin_write));
|
||||
let remote = PluginProvider::connect(host_read, host_write)
|
||||
.await
|
||||
.expect("protocol handshake");
|
||||
assert_eq!(remote.kind().as_str(), "daytona");
|
||||
let remote: Arc<dyn SandboxProvider> = Arc::new(remote);
|
||||
|
||||
let workspace = RepoWorkspace::plan(
|
||||
LayoutSource::Fixed(layout()),
|
||||
false,
|
||||
Some("https://github.com/brynary/rack-test"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
None,
|
||||
)
|
||||
.expect("clone plan");
|
||||
let snapshot = SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("snapshot id");
|
||||
let spec = DriverSpec::new(SandboxSource::Snapshot { id: snapshot });
|
||||
let spec = driver_spec(&DaytonaConfig::default(), None, &snapshot_id_of(&spec));
|
||||
let sandbox = DriverSandbox::pending(
|
||||
SandboxProviderKind::DAYTONA,
|
||||
remote,
|
||||
spec,
|
||||
Some(DEFAULT_SNAPSHOT.to_string()),
|
||||
workspace,
|
||||
);
|
||||
sandbox
|
||||
.initialize()
|
||||
.await
|
||||
.expect("initialize over the wire");
|
||||
|
||||
let checks = async {
|
||||
assert_eq!(
|
||||
sandbox.working_directory(),
|
||||
"/home/daytona/workspace/rack-test"
|
||||
);
|
||||
let result = sandbox
|
||||
.exec_command(
|
||||
"test -d /home/daytona/repos/brynary/rack-test/.git && \
|
||||
test -L /home/daytona/workspace/rack-test && \
|
||||
git rev-parse --is-inside-work-tree",
|
||||
30_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("layout check");
|
||||
assert!(result.is_success(), "{result:?}");
|
||||
assert!(result.stdout.contains("true"));
|
||||
let layout = sandbox.workspace_layout().expect("layout record");
|
||||
assert_eq!(
|
||||
layout.primary_repo_path.as_deref(),
|
||||
Some("/home/daytona/repos/brynary/rack-test")
|
||||
);
|
||||
};
|
||||
checks.await;
|
||||
sandbox.cleanup().await.expect("cleanup");
|
||||
}
|
||||
|
||||
fn snapshot_id_of(spec: &DriverSpec) -> SnapshotId {
|
||||
match &spec.source {
|
||||
SandboxSource::Snapshot { id } => id.clone(),
|
||||
_ => unreachable!("the gate builds a snapshot source"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,29 +7,31 @@ use fabro_types::{
|
|||
SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,
|
||||
};
|
||||
|
||||
use crate::driver::DaytonaCredentials;
|
||||
use crate::{daytona, docker};
|
||||
use crate::driver::ProviderAccess;
|
||||
use crate::reconnect;
|
||||
|
||||
/// Inspect the sandbox identified by `record` and return provider-neutral
|
||||
/// details for control-plane display.
|
||||
///
|
||||
/// - `local` always returns a minimal record describing the host.
|
||||
/// - `docker` describes the managed container through the sandbox driver.
|
||||
/// - `daytona` describes the sandbox through the sandbox driver.
|
||||
/// `local` always returns a minimal record describing the host; every other
|
||||
/// provider is described through the sandbox driver.
|
||||
pub async fn sandbox_details(
|
||||
record: &RunSandboxInstance,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<RunId>,
|
||||
) -> Result<SandboxDetails> {
|
||||
match record.provider.bundled() {
|
||||
Some(BundledProvider::Local) => Ok(local_details(record)),
|
||||
Some(BundledProvider::Docker) => docker_details(record, run_id).await,
|
||||
Some(BundledProvider::Daytona) => daytona_details(record, daytona, run_id).await,
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"Sandbox provider '{}' has no details implementation",
|
||||
record.provider
|
||||
)),
|
||||
if record.provider.bundled() == Some(BundledProvider::Local) {
|
||||
return Ok(local_details(record));
|
||||
}
|
||||
let sandbox = reconnect::reconnect_driver_for_run(record, access, run_id, None).await?;
|
||||
let status = sandbox.handle()?.describe().await.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to describe {} sandbox '{}': {err}",
|
||||
record.provider,
|
||||
record.runtime.id
|
||||
)
|
||||
})?;
|
||||
Ok(details_from_status(record, &status))
|
||||
}
|
||||
|
||||
fn local_details(record: &RunSandboxInstance) -> SandboxDetails {
|
||||
|
|
@ -148,52 +150,6 @@ pub(crate) fn normalize_driver_state(state: sandbox_driver::SandboxState) -> San
|
|||
}
|
||||
}
|
||||
|
||||
async fn daytona_details(
|
||||
record: &RunSandboxInstance,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
) -> Result<SandboxDetails> {
|
||||
let runtime = &record.runtime;
|
||||
let credentials = daytona.ok_or_else(|| {
|
||||
anyhow::anyhow!("Daytona sandbox details require DAYTONA_API_KEY in the vault")
|
||||
})?;
|
||||
let sandbox = daytona::attach_daytona(
|
||||
&runtime.id,
|
||||
runtime.repo_cloned.unwrap_or(false),
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
run_id,
|
||||
&credentials,
|
||||
)
|
||||
.await?;
|
||||
let status = sandbox.handle()?.describe().await.map_err(|err| {
|
||||
anyhow::anyhow!("Failed to describe Daytona sandbox '{}': {err}", runtime.id)
|
||||
})?;
|
||||
Ok(details_from_status(record, &status))
|
||||
}
|
||||
|
||||
async fn docker_details(
|
||||
record: &RunSandboxInstance,
|
||||
run_id: Option<RunId>,
|
||||
) -> Result<SandboxDetails> {
|
||||
let runtime = &record.runtime;
|
||||
let sandbox = docker::attach_docker(
|
||||
&runtime.id,
|
||||
runtime.repo_cloned.unwrap_or(false),
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
run_id,
|
||||
)
|
||||
.await?;
|
||||
let status = sandbox.handle()?.describe().await.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to describe Docker container '{}': {err}",
|
||||
runtime.id
|
||||
)
|
||||
})?;
|
||||
Ok(details_from_status(record, &status))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sandbox_driver::SandboxId;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use sandbox_driver::{
|
|||
use sandbox_driver_docker_config::DockerProviderConfig;
|
||||
|
||||
use crate::driver::{ProviderConnectOptions, connect_provider};
|
||||
use crate::driver_sandbox::{DriverSandbox, RepoWorkspace, WorkspaceLayout};
|
||||
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace, WorkspaceLayout};
|
||||
use crate::managed_labels;
|
||||
|
||||
pub const WORKING_DIRECTORY: &str = "/workspace";
|
||||
|
|
@ -140,7 +140,7 @@ pub async fn docker_sandbox(
|
|||
clone_commit_sha: Option<String>,
|
||||
) -> crate::Result<DriverSandbox> {
|
||||
let workspace = RepoWorkspace::plan(
|
||||
layout(),
|
||||
LayoutSource::Fixed(layout()),
|
||||
options.skip_clone,
|
||||
clone_origin_url.as_deref(),
|
||||
clone_branch.as_deref(),
|
||||
|
|
@ -191,8 +191,12 @@ pub async fn attach_docker(
|
|||
&status.labels,
|
||||
run_id.as_ref(),
|
||||
)?;
|
||||
let workspace =
|
||||
RepoWorkspace::attached(layout(), repo_cloned, working_directory, clone_origin_url);
|
||||
let workspace = RepoWorkspace::attached(
|
||||
LayoutSource::Fixed(layout()),
|
||||
repo_cloned,
|
||||
working_directory,
|
||||
clone_origin_url,
|
||||
);
|
||||
Ok(DriverSandbox::attached(
|
||||
SandboxProviderKind::DOCKER,
|
||||
handle,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ use std::sync::Arc;
|
|||
|
||||
use async_trait::async_trait;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings};
|
||||
use fabro_types::settings::server::{
|
||||
SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings,
|
||||
};
|
||||
use fabro_types::{BundledProvider, SandboxProviderKind};
|
||||
use sandbox_driver::{
|
||||
Capabilities, EventContext, ProviderHealth, ProviderKind, Sandbox, SandboxFilter, SandboxId,
|
||||
|
|
@ -73,6 +75,37 @@ impl std::fmt::Debug for DaytonaCredentials {
|
|||
}
|
||||
}
|
||||
|
||||
/// What a process needs to reach every provider a run record can name: the
|
||||
/// server's provider settings (which kinds are enabled, which run as
|
||||
/// plugins) and the Daytona credentials from the vault.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ProviderAccess {
|
||||
pub providers: ServerSandboxProvidersSettings,
|
||||
pub daytona: Option<DaytonaCredentials>,
|
||||
}
|
||||
|
||||
impl ProviderAccess {
|
||||
/// The settings entry for `kind`. A bundled kind without an entry is
|
||||
/// enabled with defaults; any other kind must be configured.
|
||||
pub fn settings_for(
|
||||
&self,
|
||||
kind: &SandboxProviderKind,
|
||||
) -> Option<ServerSandboxProviderSettings> {
|
||||
match self.providers.get(kind) {
|
||||
Some(settings) => Some(settings.clone()),
|
||||
None if kind.bundled().is_some() => Some(ServerSandboxProviderSettings::default()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect_options(&self) -> ProviderConnectOptions {
|
||||
ProviderConnectOptions {
|
||||
host_registry_root: None,
|
||||
daytona: self.daytona.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything besides the settings entry that a provider connection needs.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ProviderConnectOptions {
|
||||
|
|
@ -121,10 +154,12 @@ pub enum ConnectError {
|
|||
|
||||
/// Connects the provider behind `kind`.
|
||||
///
|
||||
/// Bundled kinds return the in-process driver provider. Any other kind
|
||||
/// launches the plugin named by `settings.plugin` and returns a supervised
|
||||
/// handle that relaunches it after a crash for new work only. Disabled
|
||||
/// entries are refused here so no caller has to remember the policy check.
|
||||
/// Bundled kinds return the in-process driver provider unless their entry
|
||||
/// names a plugin executable, in which case the same kind is served out of
|
||||
/// process. Any other kind launches the plugin named by `settings.plugin`.
|
||||
/// A plugin returns a supervised handle that relaunches it after a crash
|
||||
/// for new work only. Disabled entries are refused here so no caller has to
|
||||
/// remember the policy check.
|
||||
pub async fn connect_provider(
|
||||
kind: &SandboxProviderKind,
|
||||
settings: &ServerSandboxProviderSettings,
|
||||
|
|
@ -133,6 +168,12 @@ pub async fn connect_provider(
|
|||
if !settings.enabled {
|
||||
return Err(ConnectError::Disabled { kind: kind.clone() });
|
||||
}
|
||||
if let Some(plugin) = &settings.plugin {
|
||||
return Ok(ConnectedProvider {
|
||||
kind: kind.clone(),
|
||||
provider: Arc::new(PluginBackedProvider::launch(kind, plugin).await?),
|
||||
});
|
||||
}
|
||||
let driver = |source| ConnectError::Driver {
|
||||
kind: kind.clone(),
|
||||
source,
|
||||
|
|
@ -167,13 +208,7 @@ pub async fn connect_provider(
|
|||
.map_err(driver)?,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let plugin = settings
|
||||
.plugin
|
||||
.as_ref()
|
||||
.ok_or_else(|| ConnectError::MissingPluginSettings { kind: kind.clone() })?;
|
||||
Arc::new(PluginBackedProvider::launch(kind, plugin).await?)
|
||||
}
|
||||
None => return Err(ConnectError::MissingPluginSettings { kind: kind.clone() }),
|
||||
};
|
||||
Ok(ConnectedProvider {
|
||||
kind: kind.clone(),
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ pub async fn local_sandbox(working_directory: impl Into<PathBuf>) -> crate::Resu
|
|||
use crate::exec::{ExplicitEnvPolicy, SandboxExec};
|
||||
use crate::sandbox::{
|
||||
self, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult, GrepOptions, PushError,
|
||||
PushReport, Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StdioProcess,
|
||||
WalkOptions,
|
||||
PushReport, Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, SandboxWorkspaceLayout,
|
||||
StdioProcess, WalkOptions,
|
||||
};
|
||||
|
||||
/// Where a clone-based provider puts its files: the run works under
|
||||
|
|
@ -75,6 +75,27 @@ pub(crate) struct WorkspaceLayout {
|
|||
pub(crate) repos_root: String,
|
||||
}
|
||||
|
||||
impl WorkspaceLayout {
|
||||
/// The layout for a provider whose working directory fabro does not
|
||||
/// choose: repositories check out beside the workspace contents under
|
||||
/// `.repos`, and the run works in the link the workspace root carries.
|
||||
pub(crate) fn within(working_directory: &str) -> Self {
|
||||
Self {
|
||||
workspace_root: working_directory.to_string(),
|
||||
repos_root: sandbox::join_sandbox_path(working_directory, ".repos"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a workspace learns its layout.
|
||||
pub(crate) enum LayoutSource {
|
||||
/// Fabro fixes the roots before the sandbox exists.
|
||||
Fixed(WorkspaceLayout),
|
||||
/// The roots follow the provider's working directory, known once the
|
||||
/// sandbox exists.
|
||||
ProviderWorkingDirectory,
|
||||
}
|
||||
|
||||
/// What `initialize` does to the workspace once the sandbox runs.
|
||||
enum WorkspacePlan {
|
||||
/// Clone this GitHub repository into the layout.
|
||||
|
|
@ -88,7 +109,7 @@ enum WorkspacePlan {
|
|||
/// Fabro's clone-based workspace on an isolated sandbox: the layout, the
|
||||
/// clone it performs, and the GitHub credentials its checkout carries.
|
||||
pub(crate) struct RepoWorkspace {
|
||||
layout: WorkspaceLayout,
|
||||
layout: OnceLock<WorkspaceLayout>,
|
||||
plan: WorkspacePlan,
|
||||
credentials: PushCredentialState,
|
||||
repo_cloned: OnceLock<bool>,
|
||||
|
|
@ -110,7 +131,7 @@ impl RepoWorkspace {
|
|||
reason = "the clone selectors are validated together by decide_clone"
|
||||
)]
|
||||
pub(crate) fn plan(
|
||||
layout: WorkspaceLayout,
|
||||
layout: LayoutSource,
|
||||
skip_clone: bool,
|
||||
clone_origin_url: Option<&str>,
|
||||
clone_branch: Option<&str>,
|
||||
|
|
@ -146,7 +167,7 @@ impl RepoWorkspace {
|
|||
}),
|
||||
};
|
||||
Ok(Self {
|
||||
layout,
|
||||
layout: layout.into_cell(),
|
||||
plan,
|
||||
credentials,
|
||||
repo_cloned: OnceLock::new(),
|
||||
|
|
@ -160,45 +181,97 @@ impl RepoWorkspace {
|
|||
/// record. Pushes from a reattached sandbox use whatever credentials the
|
||||
/// checkout's `origin` already carries.
|
||||
pub(crate) fn attached(
|
||||
layout: WorkspaceLayout,
|
||||
layout: LayoutSource,
|
||||
repo_cloned: bool,
|
||||
working_directory: String,
|
||||
clone_origin_url: Option<String>,
|
||||
) -> Self {
|
||||
let workspace = Self {
|
||||
layout,
|
||||
plan: WorkspacePlan::Attached,
|
||||
credentials: PushCredentialState::new(None),
|
||||
repo_cloned: OnceLock::new(),
|
||||
origin_url: OnceLock::new(),
|
||||
layout: layout.into_cell(),
|
||||
plan: WorkspacePlan::Attached,
|
||||
credentials: PushCredentialState::new(None),
|
||||
repo_cloned: OnceLock::new(),
|
||||
origin_url: OnceLock::new(),
|
||||
execution_directory: OnceLock::new(),
|
||||
checkout_path: OnceLock::new(),
|
||||
checkout_path: OnceLock::new(),
|
||||
};
|
||||
let _ = workspace.repo_cloned.set(repo_cloned);
|
||||
let _ = workspace.execution_directory.set(working_directory);
|
||||
if repo_cloned {
|
||||
if let Some(origin) = clone_origin_url {
|
||||
if let Ok(repo_layout) = clone_source::github_repo_layout(
|
||||
&origin,
|
||||
&workspace.layout.workspace_root,
|
||||
&workspace.layout.repos_root,
|
||||
) {
|
||||
let _ = workspace.checkout_path.set(repo_layout.primary_repo_path);
|
||||
}
|
||||
let _ = workspace.origin_url.set(origin);
|
||||
}
|
||||
}
|
||||
workspace.derive_checkout_path();
|
||||
workspace
|
||||
}
|
||||
|
||||
/// Settle a provider-dependent layout from the sandbox's working
|
||||
/// directory. A fixed layout is left alone.
|
||||
fn resolve_layout(&self, provider_working_directory: &str) -> &WorkspaceLayout {
|
||||
let layout = self
|
||||
.layout
|
||||
.get_or_init(|| WorkspaceLayout::within(provider_working_directory));
|
||||
self.derive_checkout_path();
|
||||
layout
|
||||
}
|
||||
|
||||
/// The checkout behind an attached clone, once the layout is known.
|
||||
fn derive_checkout_path(&self) {
|
||||
if self.checkout_path.get().is_some() || !self.repo_cloned() {
|
||||
return;
|
||||
}
|
||||
let (Some(layout), Some(origin)) = (self.layout.get(), self.origin_url.get()) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(repo_layout) =
|
||||
clone_source::github_repo_layout(origin, &layout.workspace_root, &layout.repos_root)
|
||||
{
|
||||
let _ = self.checkout_path.set(repo_layout.primary_repo_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_cloned(&self) -> bool {
|
||||
self.repo_cloned.get().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
fn working_directory(&self) -> Option<&str> {
|
||||
self.execution_directory
|
||||
.get()
|
||||
.map_or(self.layout.workspace_root.as_str(), String::as_str)
|
||||
.map(String::as_str)
|
||||
.or_else(|| {
|
||||
self.layout
|
||||
.get()
|
||||
.map(|layout| layout.workspace_root.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn record(&self) -> Option<SandboxWorkspaceLayout> {
|
||||
let layout = self.layout.get()?;
|
||||
let repo = if self.repo_cloned() {
|
||||
self.origin_url.get().and_then(|origin| {
|
||||
clone_source::github_repo_layout(origin, &layout.workspace_root, &layout.repos_root)
|
||||
.ok()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(SandboxWorkspaceLayout {
|
||||
workspace_root: layout.workspace_root.clone(),
|
||||
repos_root: layout.repos_root.clone(),
|
||||
primary_repo_path: repo.as_ref().map(|repo| repo.primary_repo_path.clone()),
|
||||
primary_repo_link: repo.as_ref().map(|repo| repo.primary_repo_link.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutSource {
|
||||
fn into_cell(self) -> OnceLock<WorkspaceLayout> {
|
||||
let cell = OnceLock::new();
|
||||
if let Self::Fixed(layout) = self {
|
||||
let _ = cell.set(layout);
|
||||
}
|
||||
cell
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,6 +392,7 @@ impl DriverSandbox {
|
|||
handle: Arc<dyn DriverHandle>,
|
||||
workspace: RepoWorkspace,
|
||||
) -> Self {
|
||||
workspace.resolve_layout(handle.working_directory());
|
||||
let mut sandbox = Self::new(kind, handle);
|
||||
sandbox.workspace = Some(workspace);
|
||||
sandbox
|
||||
|
|
@ -378,11 +452,13 @@ impl DriverSandbox {
|
|||
/// resolves relative paths against the sandbox's own working directory,
|
||||
/// which sits above a cloned repository's link.
|
||||
fn resolve(&self, path: &str) -> String {
|
||||
match &self.workspace {
|
||||
Some(workspace) if workspace.execution_directory.get().is_some() => {
|
||||
sandbox::resolve_path(path, workspace.working_directory())
|
||||
}
|
||||
_ => path.to_string(),
|
||||
match self
|
||||
.workspace
|
||||
.as_ref()
|
||||
.and_then(|workspace| workspace.execution_directory.get())
|
||||
{
|
||||
Some(working_directory) => sandbox::resolve_path(path, working_directory),
|
||||
None => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,6 +522,9 @@ impl DriverSandbox {
|
|||
let Some(workspace) = &self.workspace else {
|
||||
return Ok(());
|
||||
};
|
||||
let layout = workspace
|
||||
.resolve_layout(self.handle()?.working_directory())
|
||||
.clone();
|
||||
match &workspace.plan {
|
||||
WorkspacePlan::Attached => Ok(()),
|
||||
WorkspacePlan::Empty(reason) => {
|
||||
|
|
@ -458,15 +537,18 @@ impl DriverSandbox {
|
|||
}
|
||||
self.handle()?
|
||||
.fs()
|
||||
.create_dir(&workspace.layout.workspace_root)
|
||||
.create_dir(&layout.workspace_root)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!("Failed to create {}", workspace.layout.workspace_root),
|
||||
format!("Failed to create {}", layout.workspace_root),
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
let _ = workspace.repo_cloned.set(false);
|
||||
let _ = workspace
|
||||
.execution_directory
|
||||
.set(layout.workspace_root.clone());
|
||||
Ok(())
|
||||
}
|
||||
WorkspacePlan::Clone(plan) => {
|
||||
|
|
@ -484,8 +566,8 @@ impl DriverSandbox {
|
|||
handle.as_ref(),
|
||||
&exec,
|
||||
plan,
|
||||
&workspace.layout.workspace_root,
|
||||
&workspace.layout.repos_root,
|
||||
&layout.workspace_root,
|
||||
&layout.repos_root,
|
||||
&workspace.credentials,
|
||||
)
|
||||
.await;
|
||||
|
|
@ -1021,8 +1103,12 @@ impl Sandbox for DriverSandbox {
|
|||
/// The directory the run works in: the cloned repository's link for a
|
||||
/// clone-based workspace, the provider's working directory otherwise.
|
||||
fn working_directory(&self) -> &str {
|
||||
if let Some(workspace) = &self.workspace {
|
||||
return workspace.working_directory();
|
||||
if let Some(directory) = self
|
||||
.workspace
|
||||
.as_ref()
|
||||
.and_then(RepoWorkspace::working_directory)
|
||||
{
|
||||
return directory;
|
||||
}
|
||||
self.handle
|
||||
.get()
|
||||
|
|
@ -1066,6 +1152,10 @@ impl Sandbox for DriverSandbox {
|
|||
self.snapshot.get().cloned()
|
||||
}
|
||||
|
||||
fn workspace_layout(&self) -> Option<SandboxWorkspaceLayout> {
|
||||
self.workspace.as_ref().and_then(RepoWorkspace::record)
|
||||
}
|
||||
|
||||
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
|
||||
let mut timers = LifecycleTimers::default();
|
||||
timers.auto_stop_after_idle = u64::try_from(minutes)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub mod terminal;
|
|||
|
||||
mod clone;
|
||||
pub mod docker;
|
||||
pub mod plugin;
|
||||
|
||||
pub mod daytona;
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ pub mod test_support;
|
|||
pub use daytona::{DaytonaConfig, attach_daytona, daytona_sandbox};
|
||||
pub use details::sandbox_details;
|
||||
pub use docker::{DockerSandboxOptions, attach_docker, check_docker_daemon, docker_sandbox};
|
||||
pub use driver::DaytonaCredentials;
|
||||
pub use driver::{DaytonaCredentials, ProviderAccess};
|
||||
pub use driver_sandbox::{DriverSandbox, local_sandbox};
|
||||
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
|
||||
pub use exec::{ExplicitEnvPolicy, SandboxExec, is_sensitive_env_var};
|
||||
|
|
@ -48,6 +49,7 @@ pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};
|
|||
pub use git_retry::{
|
||||
CredentialContext, GitRetryReason, RetryPlan, classify_failure, retry_git_operation,
|
||||
};
|
||||
pub use plugin::{PluginSandboxOptions, attach_plugin, plugin_sandbox};
|
||||
pub use provider::driver::DriverInventoryProvider;
|
||||
pub use provider::{
|
||||
LocalSandboxProvider, SandboxLookupError, SandboxProvider, SandboxProviderRegistry,
|
||||
|
|
@ -60,9 +62,9 @@ pub use sandbox::{
|
|||
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
|
||||
ExecStreamingRequest, ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions,
|
||||
OutputCaptureStats, PushAttempt, PushError, PushReport, RefreshOutcome, RemoteCredentialAction,
|
||||
Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StderrCollector, StdioProcess,
|
||||
StdioProcessHandle, StdioProcessTermination, WalkOptions, format_lines_numbered,
|
||||
redacted_output_tail, setup_git_via_exec, shell_quote,
|
||||
Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, SandboxWorkspaceLayout,
|
||||
StderrCollector, StdioProcess, StdioProcessHandle, StdioProcessTermination, WalkOptions,
|
||||
format_lines_numbered, redacted_output_tail, setup_git_via_exec, shell_quote,
|
||||
};
|
||||
pub use sandbox_spec::SandboxSpec;
|
||||
pub use terminal::{DriverTerminalSession, TerminalSession, TerminalSize, open_terminal_for_run};
|
||||
|
|
|
|||
358
lib/components/fabro-sandbox/src/plugin.rs
Normal file
358
lib/components/fabro-sandbox/src/plugin.rs
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
//! Sandboxes on a provider fabro does not bundle: any kind served by a
|
||||
//! sandbox-driver plugin executable, and a bundled kind an operator chose to
|
||||
//! run out of process.
|
||||
//!
|
||||
//! Fabro knows nothing about the provider beyond its declared capabilities,
|
||||
//! so the environment maps onto the normalized [`SandboxSpec`] only: an
|
||||
//! image or Dockerfile source when the environment names one (a host-style
|
||||
//! provider gets a managed directory), resources, network policy, labels,
|
||||
//! and environment variables. The provider chooses the working directory;
|
||||
//! fabro lays its repository checkout out inside it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_github::GitHubCredentials;
|
||||
use fabro_types::settings::run::{
|
||||
DockerfileSource, EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings,
|
||||
};
|
||||
use fabro_types::settings::server::ServerSandboxProviderSettings;
|
||||
use fabro_types::{RunId, SandboxProviderKind};
|
||||
use sandbox_driver::{
|
||||
Capabilities, NetworkPolicy, Resources, SandboxId, SandboxProvider, SandboxSource,
|
||||
SandboxSpec as DriverSpec,
|
||||
};
|
||||
|
||||
use crate::driver::{ProviderConnectOptions, connect_provider};
|
||||
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace};
|
||||
use crate::managed_labels;
|
||||
|
||||
/// What an environment asks of a plugin provider.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PluginSandboxOptions {
|
||||
/// Image reference, when the environment names one.
|
||||
pub image: Option<String>,
|
||||
/// Inline Dockerfile, when the environment names one instead of an
|
||||
/// image.
|
||||
pub dockerfile: Option<String>,
|
||||
/// Environment variables for the sandbox, resolved.
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub network: PluginNetwork,
|
||||
pub cpu_cores: Option<u32>,
|
||||
pub memory_mb: Option<u64>,
|
||||
pub disk_mb: Option<u64>,
|
||||
/// Labels from the environment; fabro's managed labels are added.
|
||||
pub labels: BTreeMap<String, String>,
|
||||
/// Maximum Git history depth fetched during clone; `None` fetches full
|
||||
/// history.
|
||||
pub clone_depth: Option<u32>,
|
||||
/// Create an empty workspace instead of cloning even when an origin
|
||||
/// exists.
|
||||
pub skip_clone: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PluginNetwork {
|
||||
#[default]
|
||||
ProviderDefault,
|
||||
AllowAll,
|
||||
Block,
|
||||
CidrAllowList(Vec<String>),
|
||||
}
|
||||
|
||||
/// Map a resolved environment onto plugin options. `env` is the resolved
|
||||
/// environment map (secrets substituted by the caller).
|
||||
#[must_use]
|
||||
pub fn plugin_options_from_environment(
|
||||
settings: &RunEnvironmentSettings,
|
||||
clone: &RunCloneSettings,
|
||||
env: BTreeMap<String, String>,
|
||||
) -> PluginSandboxOptions {
|
||||
PluginSandboxOptions {
|
||||
image: settings.image.docker.clone(),
|
||||
dockerfile: match &settings.image.dockerfile {
|
||||
Some(DockerfileSource::Inline(content)) if settings.image.docker.is_none() => {
|
||||
Some(content.clone())
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
env,
|
||||
network: match settings.network.mode {
|
||||
EnvironmentNetworkMode::Block => PluginNetwork::Block,
|
||||
EnvironmentNetworkMode::AllowAll => PluginNetwork::AllowAll,
|
||||
EnvironmentNetworkMode::CidrAllowList => {
|
||||
PluginNetwork::CidrAllowList(settings.network.allow.clone())
|
||||
}
|
||||
},
|
||||
cpu_cores: settings
|
||||
.resources
|
||||
.cpu
|
||||
.and_then(|cpu| u32::try_from(cpu).ok()),
|
||||
memory_mb: settings
|
||||
.resources
|
||||
.memory
|
||||
.map(|size| size.as_bytes().div_ceil(1024 * 1024)),
|
||||
disk_mb: settings
|
||||
.resources
|
||||
.disk
|
||||
.map(|size| size.as_bytes().div_ceil(1024 * 1024)),
|
||||
labels: settings
|
||||
.labels
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect(),
|
||||
clone_depth: clone
|
||||
.depth_limit()
|
||||
.and_then(|depth| u32::try_from(depth).ok()),
|
||||
skip_clone: !clone.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver spec for a fabro sandbox on a plugin provider.
|
||||
pub(crate) fn driver_spec(options: &PluginSandboxOptions, run_id: Option<&RunId>) -> DriverSpec {
|
||||
let source = match (&options.image, &options.dockerfile) {
|
||||
(Some(reference), _) => SandboxSource::Image {
|
||||
reference: reference.clone(),
|
||||
},
|
||||
(None, Some(content)) => SandboxSource::Dockerfile {
|
||||
content: content.clone(),
|
||||
},
|
||||
// A provider without images (a host-style plugin) manages a
|
||||
// workspace directory of its own.
|
||||
(None, None) => SandboxSource::HostDirectory,
|
||||
};
|
||||
let mut spec = DriverSpec::new(source).network(match &options.network {
|
||||
PluginNetwork::ProviderDefault => NetworkPolicy::ProviderDefault,
|
||||
PluginNetwork::AllowAll => NetworkPolicy::AllowAll,
|
||||
PluginNetwork::Block => NetworkPolicy::Block,
|
||||
PluginNetwork::CidrAllowList(cidrs) => NetworkPolicy::CidrAllowList {
|
||||
cidrs: cidrs.clone(),
|
||||
},
|
||||
});
|
||||
if let Some(run_id) = run_id {
|
||||
spec = spec.name(format!("fabro-run-{run_id}"));
|
||||
}
|
||||
let user_labels: std::collections::HashMap<String, String> = options
|
||||
.labels
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
let mut labels: Vec<(String, String)> =
|
||||
managed_labels::merge_for_run(Some(&user_labels), run_id)
|
||||
.into_iter()
|
||||
.collect();
|
||||
labels.sort();
|
||||
for (key, value) in labels {
|
||||
spec = spec.label(key, value);
|
||||
}
|
||||
for (key, value) in &options.env {
|
||||
spec = spec.env_var(key, value);
|
||||
}
|
||||
let mut resources = Resources::default();
|
||||
resources.cpu_cores = options.cpu_cores;
|
||||
resources.memory_mb = options.memory_mb;
|
||||
resources.disk_mb = options.disk_mb;
|
||||
spec.resources(resources)
|
||||
}
|
||||
|
||||
/// The environment's default `allow_all` means "unrestricted", which a
|
||||
/// provider without network controls already is; asking such a provider
|
||||
/// for it explicitly would be rejected. An explicit restriction is still
|
||||
/// requested, and refused by the provider when it cannot honor it.
|
||||
fn supported_network(requested: NetworkPolicy, capabilities: &Capabilities) -> NetworkPolicy {
|
||||
match requested {
|
||||
NetworkPolicy::AllowAll if !capabilities.network.allow_all => {
|
||||
NetworkPolicy::ProviderDefault
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
kind: &SandboxProviderKind,
|
||||
settings: &ServerSandboxProviderSettings,
|
||||
) -> crate::Result<Arc<dyn SandboxProvider>> {
|
||||
connect_provider(kind, settings, &ProviderConnectOptions::default())
|
||||
.await
|
||||
.map(|connected| connected.provider)
|
||||
.map_err(|error| {
|
||||
crate::Error::context(format!("Failed to connect to the {kind} provider"), error)
|
||||
})
|
||||
}
|
||||
|
||||
/// A sandbox for a run on the plugin provider `kind`. The sandbox is
|
||||
/// created by `initialize`; construction validates the clone request and
|
||||
/// launches the plugin, so a bad spec or a missing executable fails first.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "mirrors SandboxSpec::Plugin; clone inputs are validated together"
|
||||
)]
|
||||
pub async fn plugin_sandbox(
|
||||
kind: SandboxProviderKind,
|
||||
settings: &ServerSandboxProviderSettings,
|
||||
options: PluginSandboxOptions,
|
||||
github_app: Option<&GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
) -> crate::Result<DriverSandbox> {
|
||||
let workspace = RepoWorkspace::plan(
|
||||
LayoutSource::ProviderWorkingDirectory,
|
||||
options.skip_clone,
|
||||
clone_origin_url.as_deref(),
|
||||
clone_branch.as_deref(),
|
||||
clone_tag.as_deref(),
|
||||
clone_commit_sha.as_deref(),
|
||||
options.clone_depth,
|
||||
github_app,
|
||||
)?;
|
||||
let provider = connect(&kind, settings).await?;
|
||||
let mut spec = driver_spec(&options, run_id.as_ref());
|
||||
spec.network = supported_network(spec.network, provider.capabilities());
|
||||
Ok(DriverSandbox::pending(
|
||||
kind,
|
||||
provider,
|
||||
spec,
|
||||
options.image.clone(),
|
||||
workspace,
|
||||
))
|
||||
}
|
||||
|
||||
/// Reattach to a run's sandbox on the plugin provider `kind` by its
|
||||
/// persisted id. The sandbox must carry fabro's labels.
|
||||
pub async fn attach_plugin(
|
||||
kind: SandboxProviderKind,
|
||||
settings: &ServerSandboxProviderSettings,
|
||||
sandbox_id: &str,
|
||||
repo_cloned: bool,
|
||||
working_directory: String,
|
||||
clone_origin_url: Option<String>,
|
||||
run_id: Option<RunId>,
|
||||
) -> crate::Result<DriverSandbox> {
|
||||
let provider = connect(&kind, settings).await?;
|
||||
let id = SandboxId::try_new(sandbox_id)
|
||||
.map_err(|error| crate::Error::context(format!("Invalid {kind} sandbox id"), error))?;
|
||||
let handle = provider.attach(&id, None).await.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"),
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
let status = handle.describe().await?;
|
||||
managed_labels::verify_managed(&kind, sandbox_id, &status.labels, run_id.as_ref())?;
|
||||
let workspace = RepoWorkspace::attached(
|
||||
LayoutSource::ProviderWorkingDirectory,
|
||||
repo_cloned,
|
||||
working_directory,
|
||||
clone_origin_url,
|
||||
);
|
||||
Ok(DriverSandbox::attached(kind, handle, workspace))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::settings::run::{
|
||||
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings,
|
||||
EnvironmentResourcesSettings,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn environment(kind: &str) -> RunEnvironmentSettings {
|
||||
RunEnvironmentSettings {
|
||||
id: kind.to_string(),
|
||||
provider: SandboxProviderKind::try_new(kind).unwrap(),
|
||||
cwd: None,
|
||||
image: EnvironmentImageSettings::default(),
|
||||
resources: EnvironmentResourcesSettings::default(),
|
||||
network: EnvironmentNetworkSettings::default(),
|
||||
lifecycle: EnvironmentLifecycleSettings::default(),
|
||||
labels: std::collections::HashMap::from([(
|
||||
"team".to_string(),
|
||||
"platform".to_string(),
|
||||
)]),
|
||||
env: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_without_an_image_ask_for_a_managed_directory() {
|
||||
let options = plugin_options_from_environment(
|
||||
&environment("host"),
|
||||
&RunCloneSettings::default(),
|
||||
BTreeMap::from([("FOO".to_string(), "bar".to_string())]),
|
||||
);
|
||||
assert!(options.image.is_none());
|
||||
assert_eq!(options.clone_depth, Some(100));
|
||||
assert!(!options.skip_clone);
|
||||
|
||||
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
|
||||
let spec = driver_spec(&options, Some(&run_id));
|
||||
assert!(matches!(spec.source, SandboxSource::HostDirectory));
|
||||
assert!(spec.working_directory.is_none());
|
||||
assert_eq!(
|
||||
spec.name.as_deref(),
|
||||
Some("fabro-run-01HY0000000000000000000000")
|
||||
);
|
||||
assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar"));
|
||||
assert_eq!(
|
||||
spec.labels.get("team").map(String::as_str),
|
||||
Some("platform")
|
||||
);
|
||||
assert_eq!(
|
||||
spec.labels.get("sh.fabro.managed").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
spec.labels.get("sh.fabro.run_id").map(String::as_str),
|
||||
Some("01HY0000000000000000000000")
|
||||
);
|
||||
assert!(matches!(spec.network, NetworkPolicy::AllowAll));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_all_falls_back_to_the_provider_default_without_network_control() {
|
||||
let none = Capabilities::minimal(sandbox_driver::Isolation::None);
|
||||
assert!(matches!(
|
||||
supported_network(NetworkPolicy::AllowAll, &none),
|
||||
NetworkPolicy::ProviderDefault
|
||||
));
|
||||
assert!(matches!(
|
||||
supported_network(NetworkPolicy::Block, &none),
|
||||
NetworkPolicy::Block
|
||||
));
|
||||
let mut full = Capabilities::minimal(sandbox_driver::Isolation::Container);
|
||||
full.network.allow_all = true;
|
||||
assert!(matches!(
|
||||
supported_network(NetworkPolicy::AllowAll, &full),
|
||||
NetworkPolicy::AllowAll
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_with_an_image_map_resources_and_network() {
|
||||
let mut settings = environment("e2b");
|
||||
settings.image.docker = Some("ubuntu:24.04".to_string());
|
||||
settings.resources.cpu = Some(2);
|
||||
settings.network.mode = EnvironmentNetworkMode::Block;
|
||||
let clone = RunCloneSettings {
|
||||
enabled: false,
|
||||
depth: 0,
|
||||
};
|
||||
let options = plugin_options_from_environment(&settings, &clone, BTreeMap::new());
|
||||
assert_eq!(options.image.as_deref(), Some("ubuntu:24.04"));
|
||||
assert!(options.skip_clone);
|
||||
assert_eq!(options.clone_depth, None);
|
||||
|
||||
let spec = driver_spec(&options, None);
|
||||
assert!(matches!(
|
||||
&spec.source,
|
||||
SandboxSource::Image { reference } if reference == "ubuntu:24.04"
|
||||
));
|
||||
assert_eq!(spec.resources.cpu_cores, Some(2));
|
||||
assert!(matches!(spec.network, NetworkPolicy::Block));
|
||||
assert!(spec.name.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +1,38 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
|
||||
|
||||
use crate::driver::DaytonaCredentials;
|
||||
use crate::driver::ProviderAccess;
|
||||
use crate::driver_sandbox::{DriverSandbox, local_sandbox};
|
||||
use crate::{SandboxEventCallback, daytona, docker};
|
||||
use crate::{SandboxEventCallback, daytona, docker, plugin};
|
||||
|
||||
/// Reconnect to a sandbox from a saved record.
|
||||
///
|
||||
/// `daytona` carries the vault credentials a `"daytona"` record needs; the
|
||||
/// process environment is never consulted.
|
||||
/// `access` carries the provider settings and vault credentials the record's
|
||||
/// provider needs; the process environment is never consulted.
|
||||
pub async fn reconnect(
|
||||
record: &RunSandboxInstance,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
access: &ProviderAccess,
|
||||
) -> Result<Box<dyn crate::Sandbox>> {
|
||||
reconnect_for_run(record, daytona, None).await
|
||||
reconnect_for_run(record, access, None).await
|
||||
}
|
||||
|
||||
pub async fn reconnect_for_run(
|
||||
record: &RunSandboxInstance,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<RunId>,
|
||||
) -> Result<Box<dyn crate::Sandbox>> {
|
||||
reconnect_for_run_with_callback(record, daytona, run_id, None).await
|
||||
reconnect_for_run_with_callback(record, access, run_id, None).await
|
||||
}
|
||||
|
||||
pub async fn reconnect_for_run_with_callback(
|
||||
record: &RunSandboxInstance,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<RunId>,
|
||||
event_callback: Option<SandboxEventCallback>,
|
||||
) -> Result<Box<dyn crate::Sandbox>> {
|
||||
let sandbox = reconnect_driver_for_run(record, daytona, run_id, event_callback).await?;
|
||||
let sandbox = reconnect_driver_for_run(record, access, run_id, event_callback).await?;
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
|
||||
|
|
@ -41,12 +41,19 @@ pub async fn reconnect_for_run_with_callback(
|
|||
/// (VNC, signed previews, leased SSH).
|
||||
pub async fn reconnect_driver_for_run(
|
||||
record: &RunSandboxInstance,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<RunId>,
|
||||
event_callback: Option<SandboxEventCallback>,
|
||||
) -> Result<DriverSandbox> {
|
||||
let runtime = &record.runtime;
|
||||
match record.provider.bundled() {
|
||||
let settings = access.settings_for(&record.provider);
|
||||
// A bundled kind whose entry names a plugin executable is served out of
|
||||
// process, exactly as a third-party kind is.
|
||||
let plugin_served = settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| settings.plugin.is_some());
|
||||
let bundled = record.provider.bundled().filter(|_| !plugin_served);
|
||||
match bundled {
|
||||
// A local sandbox is its working directory: rebuilding the handle
|
||||
// over that directory is the reconnect. The per-process Host
|
||||
// registry holds no state worth attaching to.
|
||||
|
|
@ -81,7 +88,7 @@ pub async fn reconnect_driver_for_run(
|
|||
let repo_cloned = runtime
|
||||
.repo_cloned
|
||||
.context("Daytona run sandbox missing repo_cloned metadata")?;
|
||||
let credentials = daytona.context(
|
||||
let credentials = access.daytona.clone().context(
|
||||
"Daytona run sandbox cannot be reconnected without DAYTONA_API_KEY in the vault",
|
||||
)?;
|
||||
let mut sandbox = daytona::attach_daytona(
|
||||
|
|
@ -99,9 +106,31 @@ pub async fn reconnect_driver_for_run(
|
|||
}
|
||||
Ok(sandbox)
|
||||
}
|
||||
None => bail!(
|
||||
"sandbox provider `{}` is not bundled; plugin reconnect is not wired yet",
|
||||
record.provider
|
||||
),
|
||||
None => {
|
||||
let settings = settings.with_context(|| {
|
||||
format!(
|
||||
"sandbox provider `{}` is not configured; add [server.sandbox.providers.{}] to settings.toml",
|
||||
record.provider, record.provider
|
||||
)
|
||||
})?;
|
||||
let repo_cloned = runtime
|
||||
.repo_cloned
|
||||
.context("run sandbox missing repo_cloned metadata")?;
|
||||
let mut sandbox = plugin::attach_plugin(
|
||||
record.provider.clone(),
|
||||
&settings,
|
||||
&runtime.id,
|
||||
repo_cloned,
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
run_id,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to reconnect {} sandbox", record.provider))?;
|
||||
if let Some(callback) = event_callback {
|
||||
sandbox.set_event_callback(callback);
|
||||
}
|
||||
Ok(sandbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,17 @@ pub(crate) const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0";
|
|||
|
||||
pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Where a clone-based sandbox put its files, as persisted on the run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SandboxWorkspaceLayout {
|
||||
pub workspace_root: String,
|
||||
pub repos_root: String,
|
||||
/// The repository checkout and its link in the workspace, when a
|
||||
/// repository was cloned.
|
||||
pub primary_repo_path: Option<String>,
|
||||
pub primary_repo_link: Option<String>,
|
||||
}
|
||||
|
||||
/// Information returned when a sandbox sets up git for a workflow run.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitRunInfo {
|
||||
|
|
@ -194,6 +205,10 @@ macro_rules! delegate_sandbox {
|
|||
self.$field.snapshot_info()
|
||||
}
|
||||
|
||||
fn workspace_layout(&self) -> Option<$crate::SandboxWorkspaceLayout> {
|
||||
self.$field.workspace_layout()
|
||||
}
|
||||
|
||||
async fn refresh_push_credentials(&self) -> $crate::Result<$crate::RefreshOutcome> {
|
||||
self.$field.refresh_push_credentials().await
|
||||
}
|
||||
|
|
@ -1364,6 +1379,13 @@ pub trait Sandbox: Send + Sync {
|
|||
None
|
||||
}
|
||||
|
||||
/// The clone-based workspace layout of an initialized sandbox, for the
|
||||
/// run record. `None` for a sandbox that works in a designated
|
||||
/// directory.
|
||||
fn workspace_layout(&self) -> Option<SandboxWorkspaceLayout> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Refresh git push credentials (e.g. rotate an expiring GitHub App token).
|
||||
/// Default is a no-op; Docker/Daytona override to resolve a token through
|
||||
/// the shared source and update the remote URL when the embedded
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ use std::sync::Arc;
|
|||
|
||||
use anyhow::Context as _;
|
||||
use fabro_github::GitHubCredentials;
|
||||
use fabro_types::settings::server::ServerSandboxProviderSettings;
|
||||
use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
|
||||
|
||||
use crate::daytona::{self, DaytonaConfig};
|
||||
use crate::docker::{self, DockerSandboxOptions};
|
||||
use crate::driver::DaytonaCredentials;
|
||||
use crate::driver_sandbox::local_sandbox;
|
||||
use crate::plugin::{self, PluginSandboxOptions};
|
||||
use crate::{Sandbox, SandboxEventCallback, clone_source};
|
||||
|
||||
/// Options for sandbox initialization and construction.
|
||||
|
|
@ -38,6 +40,18 @@ pub enum SandboxSpec {
|
|||
/// environment.
|
||||
credentials: Option<DaytonaCredentials>,
|
||||
},
|
||||
/// A provider served by a sandbox-driver plugin executable.
|
||||
Plugin {
|
||||
kind: SandboxProviderKind,
|
||||
settings: Box<ServerSandboxProviderSettings>,
|
||||
options: Box<PluginSandboxOptions>,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
clone_tag: Option<String>,
|
||||
clone_commit_sha: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SandboxSpec {
|
||||
|
|
@ -46,15 +60,12 @@ impl SandboxSpec {
|
|||
Self::Local { .. } => SandboxProviderKind::LOCAL,
|
||||
Self::Docker { .. } => SandboxProviderKind::DOCKER,
|
||||
Self::Daytona { .. } => SandboxProviderKind::DAYTONA,
|
||||
Self::Plugin { kind, .. } => kind.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Local { .. } => "local",
|
||||
Self::Docker { .. } => "docker",
|
||||
Self::Daytona { .. } => "daytona",
|
||||
}
|
||||
pub fn provider_name(&self) -> String {
|
||||
self.provider().to_string()
|
||||
}
|
||||
|
||||
/// Build initialized sandbox metadata for persistence.
|
||||
|
|
@ -152,6 +163,40 @@ impl SandboxSpec {
|
|||
},
|
||||
}
|
||||
}
|
||||
Self::Plugin {
|
||||
options,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
..
|
||||
} => {
|
||||
let repo_cloned = clone_source::repo_cloned_for_record(
|
||||
options.skip_clone,
|
||||
clone_origin_url.as_deref(),
|
||||
);
|
||||
let layout = sandbox.workspace_layout();
|
||||
RunSandboxInstance {
|
||||
provider: self.provider(),
|
||||
image: options.image.clone(),
|
||||
snapshot: sandbox.snapshot_info(),
|
||||
runtime: RunSandboxRuntime {
|
||||
id,
|
||||
working_directory,
|
||||
repo_cloned,
|
||||
clone_origin_url: clone_source::clean_clone_origin_for_record(
|
||||
clone_origin_url.as_deref(),
|
||||
),
|
||||
clone_branch: clone_branch.clone(),
|
||||
workspace_root: layout.as_ref().map(|layout| layout.workspace_root.clone()),
|
||||
repos_root: layout.as_ref().map(|layout| layout.repos_root.clone()),
|
||||
primary_repo_path: layout
|
||||
.as_ref()
|
||||
.and_then(|layout| layout.primary_repo_path.clone()),
|
||||
primary_repo_link: layout
|
||||
.as_ref()
|
||||
.and_then(|layout| layout.primary_repo_link.clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Self::Local { .. } => RunSandboxInstance {
|
||||
provider: self.provider(),
|
||||
image: None,
|
||||
|
|
@ -240,6 +285,35 @@ impl SandboxSpec {
|
|||
}
|
||||
Ok(Arc::new(sandbox))
|
||||
}
|
||||
Self::Plugin {
|
||||
kind,
|
||||
settings,
|
||||
options,
|
||||
github_app,
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
} => {
|
||||
let mut sandbox = plugin::plugin_sandbox(
|
||||
kind.clone(),
|
||||
settings,
|
||||
options.as_ref().clone(),
|
||||
github_app.as_ref(),
|
||||
*run_id,
|
||||
clone_origin_url.clone(),
|
||||
clone_branch.clone(),
|
||||
clone_tag.clone(),
|
||||
clone_commit_sha.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create {kind} sandbox"))?;
|
||||
if let Some(callback) = event_callback {
|
||||
sandbox.set_event_callback(callback);
|
||||
}
|
||||
Ok(Arc::new(sandbox))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use async_trait::async_trait;
|
||||
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
|
||||
|
||||
use crate::driver::DaytonaCredentials;
|
||||
use crate::{Sandbox, daytona, docker};
|
||||
use crate::driver::ProviderAccess;
|
||||
use crate::{Sandbox, reconnect};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TerminalSize {
|
||||
|
|
@ -75,53 +75,18 @@ impl TerminalSession for DriverTerminalSession {
|
|||
|
||||
pub async fn open_terminal_for_run(
|
||||
record: &RunSandboxInstance,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<RunId>,
|
||||
size: TerminalSize,
|
||||
) -> crate::Result<Box<dyn TerminalSession>> {
|
||||
let runtime = &record.runtime;
|
||||
|
||||
match record.provider.bundled() {
|
||||
Some(BundledProvider::Daytona) => {
|
||||
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
|
||||
crate::Error::message("Daytona run sandbox is missing clone metadata")
|
||||
})?;
|
||||
let credentials = daytona.ok_or_else(|| {
|
||||
crate::Error::message("Daytona terminals require DAYTONA_API_KEY in the vault")
|
||||
})?;
|
||||
let sandbox = daytona::attach_daytona(
|
||||
&runtime.id,
|
||||
repo_cloned,
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
run_id,
|
||||
&credentials,
|
||||
)
|
||||
.await?;
|
||||
sandbox.activate().await?;
|
||||
Ok(Box::new(sandbox.open_terminal(size).await?))
|
||||
}
|
||||
Some(BundledProvider::Docker) => {
|
||||
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
|
||||
crate::Error::message("Docker run sandbox is missing clone metadata")
|
||||
})?;
|
||||
let sandbox = docker::attach_docker(
|
||||
&runtime.id,
|
||||
repo_cloned,
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
run_id,
|
||||
)
|
||||
.await?;
|
||||
sandbox.activate().await?;
|
||||
Ok(Box::new(sandbox.open_terminal(size).await?))
|
||||
}
|
||||
Some(BundledProvider::Local) => Err(crate::Error::message(
|
||||
if record.provider.bundled() == Some(BundledProvider::Local) {
|
||||
return Err(crate::Error::message(
|
||||
"Local sandboxes do not support embedded terminals",
|
||||
)),
|
||||
None => Err(crate::Error::message(format!(
|
||||
"Sandbox provider '{}' does not support embedded terminals yet",
|
||||
record.provider
|
||||
))),
|
||||
));
|
||||
}
|
||||
let sandbox = reconnect::reconnect_driver_for_run(record, access, run_id, None)
|
||||
.await
|
||||
.map_err(|err| crate::Error::context_anyhow("Failed to reconnect sandbox", err))?;
|
||||
sandbox.activate().await?;
|
||||
Ok(Box::new(sandbox.open_terminal(size).await?))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use fabro_sandbox::from_environment::{
|
|||
daytona_config_from_environment, docker_config_from_environment_with_secrets,
|
||||
local_working_directory_from_environment,
|
||||
};
|
||||
use fabro_sandbox::plugin::plugin_options_from_environment;
|
||||
use fabro_sandbox::{DaytonaCredentials, DockerSandboxOptions, SandboxSpec};
|
||||
use fabro_static::EnvVars;
|
||||
#[cfg(test)]
|
||||
|
|
@ -23,6 +24,7 @@ use fabro_types::settings::run::{
|
|||
ResolvedGithubIntegration, ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings,
|
||||
RunPrepareSettings as ResolvedRunPrepareSettings,
|
||||
};
|
||||
use fabro_types::settings::server::ServerSandboxProvidersSettings;
|
||||
use fabro_types::{
|
||||
BundledProvider, ManifestPath, RunId, RunRunnableSource, RunSpec, RunTarget,
|
||||
SandboxProviderKind, TargetValidationError,
|
||||
|
|
@ -91,6 +93,7 @@ struct RunSession {
|
|||
workflow_bundle: Option<Arc<WorkflowBundle>>,
|
||||
run_control: Option<Arc<RunControlState>>,
|
||||
vault: Arc<AsyncRwLock<Vault>>,
|
||||
sandbox_providers: ServerSandboxProvidersSettings,
|
||||
catalog: Arc<Catalog>,
|
||||
fabro_run_tools: Option<FabroRunToolServices>,
|
||||
}
|
||||
|
|
@ -117,6 +120,10 @@ pub struct StartServices {
|
|||
/// env. Empty when the github integration requests no token.
|
||||
pub github_integration: ResolvedGithubIntegration,
|
||||
pub vault: Arc<AsyncRwLock<Vault>>,
|
||||
/// The server's sandbox provider settings: which kinds are enabled and
|
||||
/// which run as plugins. The worker builds and reattaches sandboxes
|
||||
/// with them.
|
||||
pub sandbox_providers: ServerSandboxProvidersSettings,
|
||||
pub catalog: Arc<Catalog>,
|
||||
pub on_node: crate::OnNodeCallback,
|
||||
pub registry_override: Option<Arc<HandlerRegistry>>,
|
||||
|
|
@ -560,9 +567,37 @@ impl RunSession {
|
|||
}
|
||||
}
|
||||
None => {
|
||||
return Err(Error::engine(format!(
|
||||
"sandbox provider `{sandbox_provider}` is not bundled; plugin providers are not wired into run start yet"
|
||||
)));
|
||||
let settings = services
|
||||
.sandbox_providers
|
||||
.get(&sandbox_provider)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
Error::engine(format!(
|
||||
"sandbox provider `{sandbox_provider}` is not configured; add [server.sandbox.providers.{sandbox_provider}] to settings.toml"
|
||||
))
|
||||
})?;
|
||||
let env = resolved
|
||||
.environment
|
||||
.resolve_env(secret_lookup)
|
||||
.map_err(|err| {
|
||||
Error::engine_with_source("failed to resolve environment variables", err)
|
||||
})?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut options =
|
||||
plugin_options_from_environment(&resolved.environment, &resolved.clone, env);
|
||||
options.skip_clone |= clone_source.skip_clone;
|
||||
SandboxSpec::Plugin {
|
||||
kind: sandbox_provider.clone(),
|
||||
settings: Box::new(settings),
|
||||
options: Box::new(options),
|
||||
github_app: services.github_app.clone(),
|
||||
run_id: Some(record.run_id),
|
||||
clone_origin_url: clone_source.origin_url,
|
||||
clone_branch: clone_source.branch,
|
||||
clone_tag: clone_source.tag,
|
||||
clone_commit_sha: clone_source.commit_sha,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -632,6 +667,7 @@ impl RunSession {
|
|||
workflow_path,
|
||||
workflow_bundle,
|
||||
vault: services.vault,
|
||||
sandbox_providers: services.sandbox_providers,
|
||||
catalog,
|
||||
fabro_run_tools: services.fabro_run_tools,
|
||||
})
|
||||
|
|
@ -1002,6 +1038,7 @@ impl RunSession {
|
|||
hooks: self.hooks,
|
||||
sandbox_env: self.sandbox_env,
|
||||
vault: self.vault,
|
||||
sandbox_providers: self.sandbox_providers,
|
||||
git: self.git,
|
||||
registry_override: self.registry_override,
|
||||
artifact_sink: self.artifact_sink,
|
||||
|
|
@ -2582,6 +2619,7 @@ reasoning = false
|
|||
github_app: None,
|
||||
github_integration: ResolvedGithubIntegration::default(),
|
||||
vault: Arc::new(AsyncRwLock::new(start_vault(&[]))),
|
||||
sandbox_providers: ServerSandboxProvidersSettings::default(),
|
||||
catalog: test_catalog(),
|
||||
on_node: None,
|
||||
registry_override: Some(registry),
|
||||
|
|
|
|||
|
|
@ -295,6 +295,8 @@ async fn execute_test_run_with_options(
|
|||
origin_url: None,
|
||||
},
|
||||
vault: auth_test_support::empty_vault(),
|
||||
sandbox_providers:
|
||||
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
|
||||
git: git_options,
|
||||
run_control: None,
|
||||
registry_override,
|
||||
|
|
@ -357,6 +359,8 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
|
|||
origin_url: None,
|
||||
},
|
||||
vault: auth_test_support::empty_vault(),
|
||||
sandbox_providers:
|
||||
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -496,6 +500,8 @@ async fn resumed_in_flight_node_starts_a_new_stage_execution() {
|
|||
origin_url: None,
|
||||
},
|
||||
vault: auth_test_support::empty_vault(),
|
||||
sandbox_providers:
|
||||
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: Some(Arc::new(make_registry())),
|
||||
|
|
@ -607,6 +613,8 @@ async fn run_with_lifecycle(
|
|||
origin_url: None,
|
||||
},
|
||||
vault: auth_test_support::empty_vault(),
|
||||
sandbox_providers:
|
||||
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: Some(Arc::new(registry)),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use fabro_graphviz::graph;
|
|||
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner};
|
||||
use fabro_model::Catalog;
|
||||
use fabro_sandbox::{
|
||||
DaytonaCredentials, GitSetupIntent, SandboxEventCallback, SandboxSpec,
|
||||
DaytonaCredentials, GitSetupIntent, ProviderAccess, SandboxEventCallback, SandboxSpec,
|
||||
reconnect_for_run_with_callback, shell_quote,
|
||||
};
|
||||
use fabro_static::EnvVars;
|
||||
|
|
@ -432,15 +432,20 @@ pub async fn initialize(
|
|||
};
|
||||
let attach_existing = attach_instance.is_some();
|
||||
let sandbox: Arc<dyn Sandbox> = if let Some(instance) = attach_instance {
|
||||
let daytona = options
|
||||
.vault
|
||||
.read()
|
||||
.await
|
||||
.get(EnvVars::DAYTONA_API_KEY)
|
||||
.map(|api_key| DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var));
|
||||
let access = ProviderAccess {
|
||||
providers: options.sandbox_providers.clone(),
|
||||
daytona: options
|
||||
.vault
|
||||
.read()
|
||||
.await
|
||||
.get(EnvVars::DAYTONA_API_KEY)
|
||||
.map(|api_key| {
|
||||
DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var)
|
||||
}),
|
||||
};
|
||||
let sandbox = reconnect_for_run_with_callback(
|
||||
&instance,
|
||||
daytona,
|
||||
&access,
|
||||
Some(options.run_options.run_id),
|
||||
Some(Arc::clone(&sandbox_event_callback)),
|
||||
)
|
||||
|
|
@ -943,6 +948,8 @@ mod tests {
|
|||
origin_url: None,
|
||||
},
|
||||
vault: auth_test_support::empty_vault(),
|
||||
sandbox_providers:
|
||||
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1388,6 +1395,8 @@ mod tests {
|
|||
origin_url: None,
|
||||
},
|
||||
vault,
|
||||
sandbox_providers:
|
||||
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1491,6 +1500,8 @@ mod tests {
|
|||
origin_url: None,
|
||||
},
|
||||
vault: auth_test_support::empty_vault(),
|
||||
sandbox_providers:
|
||||
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
@ -1633,6 +1644,8 @@ mod tests {
|
|||
origin_url: None,
|
||||
},
|
||||
vault: auth_test_support::empty_vault(),
|
||||
sandbox_providers:
|
||||
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
|
||||
git: None,
|
||||
run_control: None,
|
||||
registry_override: None,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use fabro_template::TemplateContext;
|
|||
use fabro_types::settings::run::{
|
||||
PullRequestSettings, ResolvedGithubIntegration, RunModelControls,
|
||||
};
|
||||
use fabro_types::settings::server::ServerSandboxProvidersSettings;
|
||||
use fabro_types::{ManifestPath, RunId, RunProjection};
|
||||
use fabro_validate::{Diagnostic, Severity};
|
||||
use fabro_vault::Vault;
|
||||
|
|
@ -306,6 +307,9 @@ pub struct InitOptions {
|
|||
pub hooks: fabro_hooks::HookSettings,
|
||||
pub sandbox_env: SandboxEnvSpec,
|
||||
pub vault: Arc<AsyncRwLock<Vault>>,
|
||||
/// The server's sandbox provider settings, for reattaching a run's
|
||||
/// sandbox on resume.
|
||||
pub sandbox_providers: ServerSandboxProvidersSettings,
|
||||
pub git: Option<GitCheckpointOptions>,
|
||||
pub registry_override: Option<Arc<HandlerRegistry>>,
|
||||
pub artifact_sink: Option<ArtifactSink>,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
reason = "This integration test stages sandbox fixtures with sync std::fs."
|
||||
)]
|
||||
|
||||
use fabro_sandbox::ProviderAccess;
|
||||
use fabro_sandbox::reconnect::reconnect;
|
||||
use fabro_types::{RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
|
||||
|
||||
|
|
@ -49,7 +50,9 @@ async fn local_cp_upload_download_round_trip() {
|
|||
let scratch = tempfile::tempdir().unwrap();
|
||||
|
||||
let record = local_record(sandbox_dir.path());
|
||||
let sandbox = reconnect(&record, None).await.expect("reconnect local");
|
||||
let sandbox = reconnect(&record, &ProviderAccess::default())
|
||||
.await
|
||||
.expect("reconnect local");
|
||||
|
||||
// Upload a text file
|
||||
let content = b"hello from local cp test\n";
|
||||
|
|
@ -80,7 +83,9 @@ async fn local_cp_binary_round_trip() {
|
|||
let scratch = tempfile::tempdir().unwrap();
|
||||
|
||||
let record = local_record(sandbox_dir.path());
|
||||
let sandbox = reconnect(&record, None).await.expect("reconnect local");
|
||||
let sandbox = reconnect(&record, &ProviderAccess::default())
|
||||
.await
|
||||
.expect("reconnect local");
|
||||
|
||||
// All 256 byte values
|
||||
let binary: Vec<u8> = (0..=255).collect();
|
||||
|
|
@ -107,7 +112,9 @@ async fn local_cp_creates_parent_dirs() {
|
|||
let scratch = tempfile::tempdir().unwrap();
|
||||
|
||||
let record = local_record(sandbox_dir.path());
|
||||
let sandbox = reconnect(&record, None).await.expect("reconnect local");
|
||||
let sandbox = reconnect(&record, &ProviderAccess::default())
|
||||
.await
|
||||
.expect("reconnect local");
|
||||
|
||||
let content = b"nested file\n";
|
||||
let local_src = scratch.path().join("nested.txt");
|
||||
|
|
@ -232,7 +239,9 @@ async fn docker_cp_upload_download_round_trip() {
|
|||
let scratch = tempfile::tempdir().unwrap();
|
||||
|
||||
let record = docker_record(&container.id);
|
||||
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
|
||||
let sandbox = reconnect(&record, &ProviderAccess::default())
|
||||
.await
|
||||
.expect("reconnect docker");
|
||||
|
||||
// Upload a text file
|
||||
let content = b"hello from docker cp test\n";
|
||||
|
|
@ -261,7 +270,9 @@ async fn docker_cp_binary_round_trip() {
|
|||
let scratch = tempfile::tempdir().unwrap();
|
||||
|
||||
let record = docker_record(&container.id);
|
||||
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
|
||||
let sandbox = reconnect(&record, &ProviderAccess::default())
|
||||
.await
|
||||
.expect("reconnect docker");
|
||||
|
||||
let binary: Vec<u8> = (0..=255).collect();
|
||||
let local_src = scratch.path().join("binary.bin");
|
||||
|
|
@ -288,7 +299,9 @@ async fn docker_cp_creates_parent_dirs() {
|
|||
let scratch = tempfile::tempdir().unwrap();
|
||||
|
||||
let record = docker_record(&container.id);
|
||||
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
|
||||
let sandbox = reconnect(&record, &ProviderAccess::default())
|
||||
.await
|
||||
.expect("reconnect docker");
|
||||
|
||||
let content = b"nested docker file\n";
|
||||
let local_src = scratch.path().join("nested.txt");
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ use std::sync::Arc;
|
|||
use fabro_agent::Sandbox;
|
||||
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
||||
use fabro_sandbox::daytona::DaytonaConfig;
|
||||
use fabro_sandbox::{DaytonaCredentials, DriverSandbox, daytona_sandbox};
|
||||
use fabro_sandbox::{DaytonaCredentials, DriverSandbox, ProviderAccess, daytona_sandbox};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::{ArtifactKey, ArtifactStore};
|
||||
use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref};
|
||||
|
|
@ -1576,7 +1576,11 @@ async fn daytona_cp_upload_download_round_trip() {
|
|||
|
||||
// 3. Reconnect via the real cp::reconnect path
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let reconnected = reconnect(&record, None)
|
||||
let access = ProviderAccess {
|
||||
daytona: Some(live_daytona_credentials()),
|
||||
..ProviderAccess::default()
|
||||
};
|
||||
let reconnected = reconnect(&record, &access)
|
||||
.await
|
||||
.expect("reconnect should succeed");
|
||||
|
||||
|
|
|
|||
|
|
@ -72,20 +72,18 @@ fn resolve_sandbox(
|
|||
layer: Option<&ServerSandboxLayer>,
|
||||
errors: &mut Vec<ResolveError>,
|
||||
) -> ServerSandboxSettings {
|
||||
let _ = errors;
|
||||
let configured = layer
|
||||
.and_then(|sandbox| sandbox.providers.as_ref())
|
||||
.map(|providers| &providers.entries);
|
||||
let mut entries = BTreeMap::new();
|
||||
// Bundled providers always have a policy entry; missing means enabled.
|
||||
// Bundled providers always have a policy entry; missing means enabled
|
||||
// and served in-process.
|
||||
for kind in SandboxProviderKind::bundled_kinds() {
|
||||
let layer = configured.and_then(|entries| entries.get(&kind));
|
||||
let path = format!("server.sandbox.providers.{kind}");
|
||||
if let Some(layer) = layer {
|
||||
reject_plugin_fields_for_bundled(layer, &path, errors);
|
||||
}
|
||||
entries.insert(kind, ServerSandboxProviderSettings {
|
||||
enabled: layer.and_then(|provider| provider.enabled).unwrap_or(true),
|
||||
plugin: None,
|
||||
plugin: layer.and_then(plugin_settings),
|
||||
});
|
||||
}
|
||||
for (kind, layer) in configured.into_iter().flatten() {
|
||||
|
|
@ -94,14 +92,7 @@ fn resolve_sandbox(
|
|||
}
|
||||
entries.insert(kind.clone(), ServerSandboxProviderSettings {
|
||||
enabled: layer.enabled.unwrap_or(true),
|
||||
plugin: Some(SandboxPluginSettings {
|
||||
path: layer.path.clone(),
|
||||
sha256: layer.sha256.clone(),
|
||||
dev: layer.dev.unwrap_or(false),
|
||||
args: layer.args.clone().unwrap_or_default(),
|
||||
env: layer.env.clone().unwrap_or_default(),
|
||||
inherit_env: layer.inherit_env.clone().unwrap_or_default(),
|
||||
}),
|
||||
plugin: Some(plugin_settings(layer).unwrap_or_default()),
|
||||
});
|
||||
}
|
||||
ServerSandboxSettings {
|
||||
|
|
@ -109,37 +100,33 @@ fn resolve_sandbox(
|
|||
}
|
||||
}
|
||||
|
||||
fn reject_plugin_fields_for_bundled(
|
||||
layer: &ServerSandboxProviderLayer,
|
||||
path: &str,
|
||||
errors: &mut Vec<ResolveError>,
|
||||
) {
|
||||
/// Plugin launch settings when the entry names any. A bundled kind with
|
||||
/// none runs in-process; a bundled kind with any runs out of process
|
||||
/// through the driver's executable for that kind.
|
||||
fn plugin_settings(layer: &ServerSandboxProviderLayer) -> Option<SandboxPluginSettings> {
|
||||
let ServerSandboxProviderLayer {
|
||||
enabled: _,
|
||||
path: plugin_path,
|
||||
path,
|
||||
sha256,
|
||||
dev,
|
||||
args,
|
||||
env,
|
||||
inherit_env,
|
||||
} = layer;
|
||||
let set = [
|
||||
("path", plugin_path.is_some()),
|
||||
("sha256", sha256.is_some()),
|
||||
("dev", dev.is_some()),
|
||||
("args", args.is_some()),
|
||||
("env", env.is_some()),
|
||||
("inherit_env", inherit_env.is_some()),
|
||||
];
|
||||
for (field, is_set) in set {
|
||||
if is_set {
|
||||
errors.push(ResolveError::Invalid {
|
||||
path: format!("{path}.{field}"),
|
||||
reason: "bundled sandbox providers run in-process and take no plugin settings"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let any_set = path.is_some()
|
||||
|| sha256.is_some()
|
||||
|| dev.is_some()
|
||||
|| args.is_some()
|
||||
|| env.is_some()
|
||||
|| inherit_env.is_some();
|
||||
any_set.then(|| SandboxPluginSettings {
|
||||
path: path.clone(),
|
||||
sha256: sha256.clone(),
|
||||
dev: dev.unwrap_or(false),
|
||||
args: args.clone().unwrap_or_default(),
|
||||
env: env.clone().unwrap_or_default(),
|
||||
inherit_env: inherit_env.clone().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings {
|
||||
|
|
|
|||
|
|
@ -285,8 +285,8 @@ E2B_API_URL = "https://api.e2b.example"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn server_sandbox_rejects_plugin_settings_on_bundled_providers() {
|
||||
let err = ServerSettingsBuilder::from_toml(
|
||||
fn server_sandbox_plugin_settings_on_a_bundled_provider_serve_it_out_of_process() {
|
||||
let settings = ServerSettingsBuilder::from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
|
|
@ -295,15 +295,33 @@ methods = ["dev-token"]
|
|||
|
||||
[server.sandbox.providers.docker]
|
||||
path = "/usr/local/bin/fabro-sandbox-docker"
|
||||
dev = true
|
||||
inherit_env = ["PATH", "DOCKER_HOST"]
|
||||
"#,
|
||||
)
|
||||
.expect_err("bundled providers take no plugin settings");
|
||||
.expect("bundled providers accept plugin settings");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("server.sandbox.providers.docker.path"),
|
||||
"unexpected error: {err}"
|
||||
let providers = &settings.server.sandbox.providers;
|
||||
let docker = providers
|
||||
.get(&SandboxProviderKind::DOCKER)
|
||||
.expect("docker entry");
|
||||
assert!(docker.enabled);
|
||||
let plugin = docker.plugin.as_ref().expect("docker runs as a plugin");
|
||||
assert_eq!(
|
||||
plugin.path.as_deref(),
|
||||
Some("/usr/local/bin/fabro-sandbox-docker")
|
||||
);
|
||||
assert!(plugin.dev);
|
||||
assert_eq!(plugin.inherit_env, vec!["PATH", "DOCKER_HOST"]);
|
||||
assert!(
|
||||
providers
|
||||
.get(&SandboxProviderKind::LOCAL)
|
||||
.expect("local entry")
|
||||
.plugin
|
||||
.is_none(),
|
||||
"an entry without plugin keys stays in-process"
|
||||
);
|
||||
assert_eq!(providers.enabled_plugins().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
8
lib/foundation/fabro-db/build.rs
Normal file
8
lib/foundation/fabro-db/build.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
//! `sqlx::migrate!` embeds every file under `migrations/` at compile time,
|
||||
//! but on stable Rust the macro cannot tell Cargo about the directory. Without
|
||||
//! this hint a newly added migration file does not recompile the crate, so a
|
||||
//! stale build silently ships without it.
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue