mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
Add the single sandbox provider construction function
fabro_sandbox::driver::connect_provider turns one [server.sandbox.providers.<kind>] entry into an Arc<dyn SandboxProvider> from the sandbox-driver crates. Bundled kinds link the driver's Host, Docker, and Daytona providers in-process; Daytona receives its API key and endpoint explicitly through connect_explicit with a fabro-sandbox/<version> user agent, never from the process environment. Any other kind launches the configured plugin executable through a PluginSupervisor and returns a wrapper that replaces a crashed plugin for new work only, never replaying a failed call. Disabled entries are refused at the construction point. The Host and Docker providers also ship as fabro-sandbox-host and fabro-sandbox-docker plugin executables so CI can drive the bundled providers over stdio and a deployment can move one out of process by configuration alone. An integration test registers the Host executable under the non-bundled kind `host`, creates a sandbox and runs a command over the wire, then attaches to it by persisted id from a fresh plugin process, and checks that a plugin declaring a different kind is refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
80bc51c40e
commit
4dcbcec400
7 changed files with 611 additions and 0 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -3004,6 +3004,7 @@ dependencies = [
|
|||
"hmac 0.12.1",
|
||||
"httpmock",
|
||||
"rand 0.9.4",
|
||||
"reqwest 0.13.2",
|
||||
"reqwest-middleware",
|
||||
"rustls",
|
||||
"sandbox-driver",
|
||||
|
|
@ -3025,6 +3026,7 @@ dependencies = [
|
|||
"tokio-util",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ test-support = []
|
|||
[lib]
|
||||
doctest = false
|
||||
|
||||
[[bin]]
|
||||
name = "fabro-sandbox-host"
|
||||
path = "src/bin/fabro-sandbox-host.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "fabro-sandbox-docker"
|
||||
path = "src/bin/fabro-sandbox-docker.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
|
@ -36,6 +44,8 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
reqwest.workspace = true
|
||||
base64.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
|
|
|
|||
43
lib/components/fabro-sandbox/src/bin/fabro-sandbox-docker.rs
Normal file
43
lib/components/fabro-sandbox/src/bin/fabro-sandbox-docker.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! The bundled Docker provider served as a sandbox-driver plugin over stdio.
|
||||
//!
|
||||
//! Fabro links Docker in-process for normal runs. This executable exists so
|
||||
//! CI can run the same provider through the plugin protocol and so a
|
||||
//! deployment can move it out of process. The daemon named by `DOCKER_HOST`
|
||||
//! (or the local default) is not required to answer at launch: an
|
||||
//! unreachable daemon is reported through `provider/health`. Stdout belongs
|
||||
//! to the protocol; logs go to stderr.
|
||||
|
||||
use std::io::stderr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use sandbox_driver_docker::DockerProvider;
|
||||
use sandbox_driver_protocol::serve_stdio;
|
||||
use tracing_subscriber::filter::LevelFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
// Stdout carries the protocol, so diagnostics must go to the process
|
||||
// stderr handle; tracing's writer contract is synchronous.
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "tracing-subscriber requires a synchronous writer and stdout is reserved for \
|
||||
the plugin protocol"
|
||||
)]
|
||||
let diagnostics = fmt::layer().with_writer(stderr);
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(diagnostics)
|
||||
.try_init()
|
||||
.context("configuring docker plugin diagnostics")?;
|
||||
|
||||
let provider = DockerProvider::connect_unverified().context("configuring the docker client")?;
|
||||
serve_stdio(Arc::new(provider))
|
||||
.await
|
||||
.context("serving the docker provider plugin")
|
||||
}
|
||||
55
lib/components/fabro-sandbox/src/bin/fabro-sandbox-host.rs
Normal file
55
lib/components/fabro-sandbox/src/bin/fabro-sandbox-host.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//! The bundled Host provider served as a sandbox-driver plugin over stdio.
|
||||
//!
|
||||
//! Fabro links Host in-process for normal runs. This executable exists so
|
||||
//! CI can run the same provider through the plugin protocol and so a
|
||||
//! deployment can move it out of process by configuring
|
||||
//! `[server.sandbox.providers.host]` instead of `local`. Stdout belongs to
|
||||
//! the protocol; logs go to stderr. `SANDBOX_DRIVER_HOST_REGISTRY` names a
|
||||
//! persistent registry directory; without it sandboxes live in a fresh
|
||||
//! temporary registry.
|
||||
|
||||
use std::io::stderr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use sandbox_driver_host::HostProvider;
|
||||
use sandbox_driver_protocol::serve_stdio;
|
||||
use tracing_subscriber::filter::LevelFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
// Stdout carries the protocol, so diagnostics must go to the process
|
||||
// stderr handle; tracing's writer contract is synchronous.
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "tracing-subscriber requires a synchronous writer and stdout is reserved for \
|
||||
the plugin protocol"
|
||||
)]
|
||||
let diagnostics = fmt::layer().with_writer(stderr);
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(diagnostics)
|
||||
.try_init()
|
||||
.context("configuring host plugin diagnostics")?;
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "a plugin executable starts from the scrubbed environment its host declared; \
|
||||
reading it here is the configured channel"
|
||||
)]
|
||||
let registry = std::env::var_os("SANDBOX_DRIVER_HOST_REGISTRY");
|
||||
let provider = match registry {
|
||||
Some(root) => HostProvider::with_registry(root)
|
||||
.await
|
||||
.context("opening the host registry")?,
|
||||
None => HostProvider::new(),
|
||||
};
|
||||
serve_stdio(Arc::new(provider))
|
||||
.await
|
||||
.context("serving the host provider plugin")
|
||||
}
|
||||
389
lib/components/fabro-sandbox/src/driver.rs
Normal file
389
lib/components/fabro-sandbox/src/driver.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
//! The one place fabro turns provider configuration into a sandbox-driver
|
||||
//! [`SandboxProvider`].
|
||||
//!
|
||||
//! Bundled kinds (`local`, `docker`, `daytona`) link the driver's provider
|
||||
//! crates in-process. Any other kind launches the configured plugin
|
||||
//! executable over stdio and supervises it. Callers never learn which they
|
||||
//! got: both come back as `Arc<dyn SandboxProvider>` tagged with fabro's own
|
||||
//! [`SandboxProviderKind`], which is what run records and inventory persist.
|
||||
//!
|
||||
//! Credentials arrive explicitly. Nothing here reads the process environment:
|
||||
//! the Daytona key comes from the vault through [`DaytonaCredentials`], and a
|
||||
//! plugin starts from a scrubbed environment containing only what its
|
||||
//! settings declare.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings};
|
||||
use fabro_types::{BundledProvider, SandboxProviderKind};
|
||||
use sandbox_driver::{
|
||||
Capabilities, EventContext, ProviderHealth, ProviderKind, Sandbox, SandboxFilter, SandboxId,
|
||||
SandboxProvider, SandboxSpec, SandboxStatus, SnapshotProvider, VolumeProvider,
|
||||
};
|
||||
use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider};
|
||||
use sandbox_driver_docker::DockerProvider;
|
||||
use sandbox_driver_host::HostProvider;
|
||||
use sandbox_driver_protocol::{PluginConfig, PluginSupervisor};
|
||||
|
||||
/// Binary naming prefix for plugin discovery: a plugin for kind `e2b` is
|
||||
/// `fabro-sandbox-e2b` on `PATH` unless the settings name a path.
|
||||
pub const PLUGIN_BINARY_PREFIX: &str = "fabro-sandbox";
|
||||
|
||||
/// `User-Agent` fabro presents to remote sandbox control planes.
|
||||
pub const USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Explicit Daytona credentials. The process environment is never consulted.
|
||||
#[derive(Clone)]
|
||||
pub struct DaytonaCredentials {
|
||||
pub api_key: String,
|
||||
pub api_url: Option<String>,
|
||||
pub organization_id: Option<String>,
|
||||
pub target: Option<String>,
|
||||
/// Shared HTTP client; tests pass a no-proxy client here.
|
||||
pub http_client: Option<reqwest::Client>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DaytonaCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DaytonaCredentials")
|
||||
.field("api_url", &self.api_url)
|
||||
.field("organization_id", &self.organization_id)
|
||||
.field("target", &self.target)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything besides the settings entry that a provider connection needs.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ProviderConnectOptions {
|
||||
/// Directory where the in-process Host provider records its sandboxes so
|
||||
/// they survive a server restart. `None` uses a fresh temporary registry
|
||||
/// that is removed when the provider drops.
|
||||
pub host_registry_root: Option<PathBuf>,
|
||||
/// Required to connect the bundled Daytona provider.
|
||||
pub daytona: Option<DaytonaCredentials>,
|
||||
}
|
||||
|
||||
/// A provider fabro connected, tagged with the kind fabro persists for it.
|
||||
///
|
||||
/// The driver's own `provider.kind()` may differ from fabro's kind: fabro's
|
||||
/// `local` is the driver's `host`. Persist and dispatch on `kind`, never on
|
||||
/// the driver's name.
|
||||
#[derive(Clone)]
|
||||
pub struct ConnectedProvider {
|
||||
pub kind: SandboxProviderKind,
|
||||
pub provider: Arc<dyn SandboxProvider>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConnectError {
|
||||
#[error("sandbox provider `{kind}` is disabled by server.sandbox.providers.{kind}.enabled")]
|
||||
Disabled { kind: SandboxProviderKind },
|
||||
#[error(
|
||||
"sandbox provider `{kind}` has no plugin settings; add server.sandbox.providers.{kind}"
|
||||
)]
|
||||
MissingPluginSettings { kind: SandboxProviderKind },
|
||||
#[error("sandbox provider `daytona` requires DAYTONA_API_KEY in the vault")]
|
||||
MissingDaytonaCredentials,
|
||||
#[error("sandbox provider `{kind}` is not a valid sandbox-driver kind")]
|
||||
InvalidKind {
|
||||
kind: SandboxProviderKind,
|
||||
#[source]
|
||||
source: sandbox_driver::InvalidIdError,
|
||||
},
|
||||
#[error("failed to connect sandbox provider `{kind}`")]
|
||||
Driver {
|
||||
kind: SandboxProviderKind,
|
||||
#[source]
|
||||
source: sandbox_driver::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub async fn connect_provider(
|
||||
kind: &SandboxProviderKind,
|
||||
settings: &ServerSandboxProviderSettings,
|
||||
options: &ProviderConnectOptions,
|
||||
) -> Result<ConnectedProvider, ConnectError> {
|
||||
if !settings.enabled {
|
||||
return Err(ConnectError::Disabled { kind: kind.clone() });
|
||||
}
|
||||
let driver = |source| ConnectError::Driver {
|
||||
kind: kind.clone(),
|
||||
source,
|
||||
};
|
||||
let provider: Arc<dyn SandboxProvider> = match kind.bundled() {
|
||||
Some(BundledProvider::Local) => match &options.host_registry_root {
|
||||
Some(root) => Arc::new(HostProvider::with_registry(root).await.map_err(driver)?),
|
||||
None => Arc::new(HostProvider::new()),
|
||||
},
|
||||
Some(BundledProvider::Docker) => {
|
||||
// The daemon is not required to answer at connect time; `health`
|
||||
// reports an unreachable daemon so preflight sees the cause.
|
||||
Arc::new(DockerProvider::connect_unverified().map_err(driver)?)
|
||||
}
|
||||
Some(BundledProvider::Daytona) => {
|
||||
let credentials = options
|
||||
.daytona
|
||||
.as_ref()
|
||||
.ok_or(ConnectError::MissingDaytonaCredentials)?;
|
||||
let config = DaytonaConfig {
|
||||
api_key: Some(credentials.api_key.clone()),
|
||||
jwt_token: None,
|
||||
organization_id: credentials.organization_id.clone(),
|
||||
api_url: credentials.api_url.clone(),
|
||||
target: credentials.target.clone(),
|
||||
http_client: credentials.http_client.clone(),
|
||||
user_agent: Some(USER_AGENT.to_string()),
|
||||
};
|
||||
Arc::new(
|
||||
DaytonaProvider::connect_explicit(config)
|
||||
.await
|
||||
.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?)
|
||||
}
|
||||
};
|
||||
Ok(ConnectedProvider {
|
||||
kind: kind.clone(),
|
||||
provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// A plugin provider that survives its executable crashing.
|
||||
///
|
||||
/// Wraps a [`PluginSupervisor`]: every call obtains the current plugin
|
||||
/// generation, and a closed transport is replaced with a fresh launch before
|
||||
/// the call. A failed call is never replayed, and handles obtained from an
|
||||
/// earlier generation stay bound to it; callers rebuild them through
|
||||
/// [`SandboxProvider::attach`] with the persisted sandbox id.
|
||||
pub struct PluginBackedProvider {
|
||||
kind: ProviderKind,
|
||||
capabilities: Capabilities,
|
||||
supervisor: PluginSupervisor,
|
||||
}
|
||||
|
||||
impl PluginBackedProvider {
|
||||
async fn launch(
|
||||
kind: &SandboxProviderKind,
|
||||
settings: &SandboxPluginSettings,
|
||||
) -> Result<Self, ConnectError> {
|
||||
let driver_kind =
|
||||
ProviderKind::try_new(kind.as_str()).map_err(|source| ConnectError::InvalidKind {
|
||||
kind: kind.clone(),
|
||||
source,
|
||||
})?;
|
||||
let supervisor = PluginSupervisor::new(
|
||||
PLUGIN_BINARY_PREFIX,
|
||||
plugin_config(driver_kind.clone(), settings),
|
||||
);
|
||||
// Launch once now so a misconfigured plugin fails at connect time and
|
||||
// the declared capabilities are known for preflight.
|
||||
let capabilities = supervisor
|
||||
.current()
|
||||
.await
|
||||
.map_err(|source| ConnectError::Driver {
|
||||
kind: kind.clone(),
|
||||
source,
|
||||
})?
|
||||
.capabilities()
|
||||
.clone();
|
||||
Ok(Self {
|
||||
kind: driver_kind,
|
||||
capabilities,
|
||||
supervisor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Asks the current plugin generation to exit and reaps it.
|
||||
pub async fn shutdown(&self) -> sandbox_driver::Result<()> {
|
||||
self.supervisor.shutdown().await
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_config(kind: ProviderKind, settings: &SandboxPluginSettings) -> PluginConfig {
|
||||
PluginConfig {
|
||||
kind,
|
||||
path: settings.path.as_deref().map(PathBuf::from),
|
||||
sha256: settings.sha256.clone(),
|
||||
dev: settings.dev,
|
||||
args: settings.args.clone(),
|
||||
env: settings
|
||||
.env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
inherit_env: settings.inherit_env.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SandboxProvider for PluginBackedProvider {
|
||||
fn kind(&self) -> &ProviderKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> &Capabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
spec: &SandboxSpec,
|
||||
events: Option<EventContext>,
|
||||
) -> sandbox_driver::Result<Arc<dyn Sandbox>> {
|
||||
self.supervisor.current().await?.create(spec, events).await
|
||||
}
|
||||
|
||||
async fn attach(
|
||||
&self,
|
||||
id: &SandboxId,
|
||||
events: Option<EventContext>,
|
||||
) -> sandbox_driver::Result<Arc<dyn Sandbox>> {
|
||||
self.supervisor.current().await?.attach(id, events).await
|
||||
}
|
||||
|
||||
async fn undelete(
|
||||
&self,
|
||||
id: &SandboxId,
|
||||
events: Option<EventContext>,
|
||||
) -> sandbox_driver::Result<Arc<dyn Sandbox>> {
|
||||
self.supervisor.current().await?.undelete(id, events).await
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &SandboxId,
|
||||
events: Option<EventContext>,
|
||||
) -> sandbox_driver::Result<()> {
|
||||
self.supervisor.current().await?.delete(id, events).await
|
||||
}
|
||||
|
||||
async fn list(&self, filter: &SandboxFilter) -> sandbox_driver::Result<Vec<SandboxStatus>> {
|
||||
self.supervisor.current().await?.list(filter).await
|
||||
}
|
||||
|
||||
async fn health(&self) -> sandbox_driver::Result<ProviderHealth> {
|
||||
self.supervisor.current().await?.health().await
|
||||
}
|
||||
|
||||
/// Snapshot and volume management cross the wire per plugin generation,
|
||||
/// which these borrowing accessors cannot express. Fabro drives
|
||||
/// snapshots on the bundled Daytona provider only, so a plugin reports
|
||||
/// none until a generation-aware accessor exists.
|
||||
fn snapshots(&self) -> Option<&dyn SnapshotProvider> {
|
||||
None
|
||||
}
|
||||
|
||||
fn volumes(&self) -> Option<&dyn VolumeProvider> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(plugin: Option<SandboxPluginSettings>) -> ServerSandboxProviderSettings {
|
||||
ServerSandboxProviderSettings {
|
||||
enabled: true,
|
||||
plugin,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_entries_are_refused_before_any_connection() {
|
||||
let error = connect_provider(
|
||||
&SandboxProviderKind::DOCKER,
|
||||
&ServerSandboxProviderSettings {
|
||||
enabled: false,
|
||||
plugin: None,
|
||||
},
|
||||
&ProviderConnectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("disabled provider must not connect");
|
||||
assert!(
|
||||
matches!(error, ConnectError::Disabled { kind } if kind == SandboxProviderKind::DOCKER)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daytona_requires_explicit_credentials() {
|
||||
let error = connect_provider(
|
||||
&SandboxProviderKind::DAYTONA,
|
||||
&settings(None),
|
||||
&ProviderConnectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("daytona must not fall back to the environment");
|
||||
assert!(matches!(error, ConnectError::MissingDaytonaCredentials));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_kinds_require_plugin_settings() {
|
||||
let kind = SandboxProviderKind::try_new("e2b").unwrap();
|
||||
let error = connect_provider(&kind, &settings(None), &ProviderConnectOptions::default())
|
||||
.await
|
||||
.err()
|
||||
.expect("a plugin kind without settings cannot launch");
|
||||
assert!(matches!(error, ConnectError::MissingPluginSettings { kind: k } if k == kind));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_connects_the_host_provider_in_process() {
|
||||
let registry = tempfile::tempdir().unwrap();
|
||||
let connected = connect_provider(
|
||||
&SandboxProviderKind::LOCAL,
|
||||
&settings(None),
|
||||
&ProviderConnectOptions {
|
||||
host_registry_root: Some(registry.path().to_path_buf()),
|
||||
daytona: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("host provider connects without external services");
|
||||
assert_eq!(connected.kind, SandboxProviderKind::LOCAL);
|
||||
assert_eq!(connected.provider.kind().as_str(), "host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_config_carries_every_launch_setting() {
|
||||
let config = plugin_config(
|
||||
ProviderKind::try_new("e2b").unwrap(),
|
||||
&SandboxPluginSettings {
|
||||
path: Some("/opt/e2b".to_string()),
|
||||
sha256: Some("abc".to_string()),
|
||||
dev: true,
|
||||
args: vec!["--flag".to_string()],
|
||||
env: BTreeMap::from([("A".to_string(), "1".to_string())]),
|
||||
inherit_env: vec!["PATH".to_string()],
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
config.path.as_deref(),
|
||||
Some(std::path::Path::new("/opt/e2b"))
|
||||
);
|
||||
assert_eq!(config.sha256.as_deref(), Some("abc"));
|
||||
assert!(config.dev);
|
||||
assert_eq!(config.args, vec!["--flag"]);
|
||||
assert_eq!(config.env.get("A").map(String::as_str), Some("1"));
|
||||
assert_eq!(config.inherit_env, vec!["PATH"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@ pub mod redact;
|
|||
|
||||
pub mod details;
|
||||
|
||||
pub mod driver;
|
||||
|
||||
pub mod reconnect;
|
||||
|
||||
pub mod terminal;
|
||||
|
|
|
|||
110
lib/components/fabro-sandbox/tests/plugin_provider.rs
Normal file
110
lib/components/fabro-sandbox/tests/plugin_provider.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! The construction function serves a non-bundled kind through a plugin
|
||||
//! executable, and a sandbox created through one plugin generation is
|
||||
//! reachable by persisted id from a fresh connection.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_sandbox::driver::{ProviderConnectOptions, connect_provider};
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings};
|
||||
use sandbox_driver::{ExecSpec, SandboxId, SandboxSource, SandboxSpec};
|
||||
|
||||
const HOST_PLUGIN: &str = env!("CARGO_BIN_EXE_fabro-sandbox-host");
|
||||
|
||||
fn host_plugin_settings(registry: &std::path::Path) -> ServerSandboxProviderSettings {
|
||||
ServerSandboxProviderSettings {
|
||||
enabled: true,
|
||||
plugin: Some(SandboxPluginSettings {
|
||||
path: Some(HOST_PLUGIN.to_string()),
|
||||
sha256: None,
|
||||
dev: true,
|
||||
args: Vec::new(),
|
||||
env: BTreeMap::from([(
|
||||
"SANDBOX_DRIVER_HOST_REGISTRY".to_string(),
|
||||
registry.display().to_string(),
|
||||
)]),
|
||||
inherit_env: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_plugin_under_a_non_bundled_kind_creates_and_reattaches_by_persisted_id() {
|
||||
let registry = tempfile::tempdir().expect("registry tempdir");
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let kind = SandboxProviderKind::try_new("host").expect("host is a valid kind");
|
||||
assert_eq!(
|
||||
kind.bundled(),
|
||||
None,
|
||||
"host is not one of fabro's bundled kinds"
|
||||
);
|
||||
let settings = host_plugin_settings(registry.path());
|
||||
|
||||
let persisted_id: SandboxId = {
|
||||
let connected = connect_provider(&kind, &settings, &ProviderConnectOptions::default())
|
||||
.await
|
||||
.expect("plugin launches");
|
||||
assert_eq!(connected.kind, kind);
|
||||
assert_eq!(connected.provider.kind().as_str(), "host");
|
||||
let spec = SandboxSpec::new(SandboxSource::HostDirectory)
|
||||
.working_directory(workspace.path().display().to_string())
|
||||
.label("sh.fabro.managed", "true");
|
||||
let sandbox = connected
|
||||
.provider
|
||||
.create(&spec, None)
|
||||
.await
|
||||
.expect("create over the wire");
|
||||
let result = sandbox
|
||||
.exec()
|
||||
.run(&ExecSpec::bash(
|
||||
"printf hello > marker.txt && cat marker.txt",
|
||||
))
|
||||
.await
|
||||
.expect("exec over the wire");
|
||||
assert!(result.success(), "{result:?}");
|
||||
assert_eq!(result.stdout_lossy(), "hello");
|
||||
sandbox.id().clone()
|
||||
};
|
||||
|
||||
// A fresh connection is a new plugin process; the id alone must be
|
||||
// enough to find the sandbox again, exactly as run reconnect will do.
|
||||
let connected = connect_provider(&kind, &settings, &ProviderConnectOptions::default())
|
||||
.await
|
||||
.expect("plugin relaunches");
|
||||
let sandbox = connected
|
||||
.provider
|
||||
.attach(&persisted_id, None)
|
||||
.await
|
||||
.expect("attach by persisted id");
|
||||
let content = sandbox
|
||||
.fs()
|
||||
.read("marker.txt")
|
||||
.await
|
||||
.expect("file survives across plugin generations");
|
||||
assert_eq!(content, b"hello");
|
||||
assert!(workspace.path().join("marker.txt").is_file());
|
||||
sandbox.delete().await.expect("delete releases the handle");
|
||||
assert!(
|
||||
workspace.path().is_dir(),
|
||||
"designated directories are never removed by delete"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_plugin_that_declares_another_kind_is_rejected() {
|
||||
let registry = tempfile::tempdir().expect("registry tempdir");
|
||||
let kind = SandboxProviderKind::try_new("e2b").expect("valid kind");
|
||||
let error = connect_provider(
|
||||
&kind,
|
||||
&host_plugin_settings(registry.path()),
|
||||
&ProviderConnectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("the host executable declares `host`, not `e2b`");
|
||||
let rendered = format!(
|
||||
"{error}: {:?}",
|
||||
std::error::Error::source(&error).map(ToString::to_string)
|
||||
);
|
||||
assert!(rendered.contains("e2b"), "{rendered}");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue