diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index a2c040300..7c0fb1dd0 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -160,8 +160,7 @@ pub trait Sandbox: Send + Sync { | Type | Description | |---|---| | `local_sandbox(...)` | Executes directly on the local filesystem through the sandbox driver Host provider. | -| `docker_sandbox(...)` | Runs inside a Docker container through the sandbox driver. | -| `daytona_sandbox(...)` | Runs inside a Daytona cloud sandbox through the sandbox driver. | +| `provider_sandbox(kind, ...)` | Runs on any sandbox driver provider by kind: the bundled `docker` and `daytona` providers in process, or a configured plugin. | ### Provider profiles diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 84b153733..94d2d82aa 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -17,14 +17,11 @@ use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe}; use fabro_model::{Catalog, ProviderId}; -use fabro_sandbox::daytona::DaytonaConfig; -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::{DockerSandboxOptions, ProviderAccess, Sandbox, SandboxSpec}; +use fabro_sandbox::{ + ProviderAccess, ProviderSandboxSpec, Sandbox, SandboxSpec, + local_working_directory_from_environment, options_from_environment, unresolved_env, +}; use fabro_static::EnvVars; use fabro_types::settings::ModelRef; use fabro_types::settings::cli::OutputVerbosity; @@ -674,14 +671,6 @@ pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProv configured_sandbox_provider(settings).effective_for(settings.execution.mode) } -fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig { - daytona_config_from_environment(&settings.environment, &settings.clone) -} - -fn resolve_docker_config(settings: &RunNamespace) -> DockerSandboxOptions { - docker_config_from_environment(&settings.environment, &settings.clone) -} - #[derive(Clone, Debug, PartialEq, Eq)] struct GitRemoteRefCheck { origin_url: String, @@ -928,78 +917,32 @@ fn preflight_sandbox_spec( .map(|git| fabro_github::normalize_repo_origin_url(&git.origin_url)); let clone_branch = prepared.git.as_ref().map(|git| git.branch.clone()); - Ok(match sandbox_provider.bundled() { - Some(BundledProvider::Local) => { - let working_directory = local_working_directory_from_environment( - &resolved_run.environment, - Some(&prepared.source_directory), - )?; - SandboxSpec::Local { working_directory } - } - Some(BundledProvider::Docker) => { - let mut config = resolve_docker_config(resolved_run); - config.skip_clone = true; - SandboxSpec::Docker { - config, - github_app, - run_id: None, - clone_origin_url, - clone_branch, - clone_tag: None, - clone_commit_sha: None, - } - } - Some(BundledProvider::Daytona) => { - let mut config = resolve_daytona_config(resolved_run); - config.skip_clone = true; - SandboxSpec::Daytona { - config: Box::new(config), - github_app, - run_id: None, - clone_origin_url, - clone_branch, - clone_tag: None, - clone_commit_sha: None, - credentials: access.daytona.clone(), - } - } - None => { - 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, - } - } - }) + if sandbox_provider.bundled() == Some(BundledProvider::Local) { + let working_directory = local_working_directory_from_environment( + &resolved_run.environment, + Some(&prepared.source_directory), + )?; + return Ok(SandboxSpec::Local { working_directory }); + } + // No vault is available on this path, so a `{{ secrets.* }}` value keeps + // its source form. + let mut options = options_from_environment( + &resolved_run.environment, + &resolved_run.clone, + unresolved_env(&resolved_run.environment), + )?; + options.skip_clone = true; + Ok(SandboxSpec::Provider(Box::new(ProviderSandboxSpec { + kind: sandbox_provider.clone(), + access: access.clone(), + options, + github_app, + run_id: None, + clone_origin_url, + clone_branch, + clone_tag: None, + clone_commit_sha: None, + }))) } async fn run_sandbox_check( @@ -2278,18 +2221,14 @@ provider = "local" ); match spec { - Ok(SandboxSpec::Docker { - config, - clone_origin_url, - clone_branch, - .. - }) => { - assert!(config.skip_clone); + Ok(SandboxSpec::Provider(spec)) => { + assert_eq!(spec.kind, SandboxProviderKind::DOCKER); + assert!(spec.options.skip_clone); assert_eq!( - clone_origin_url.as_deref(), + spec.clone_origin_url.as_deref(), Some("https://github.com/acme/widgets") ); - assert_eq!(clone_branch.as_deref(), Some("main")); + assert_eq!(spec.clone_branch.as_deref(), Some("main")); } _ => panic!("expected Docker preflight sandbox spec"), } diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index 05867e821..8b33bd8d9 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -38,7 +38,7 @@ pub use config::{ pub use error::{CompactionError, Error, InterruptReason, Result}; pub use event::Emitter; pub use fabro_mcp::config::McpServerSettings; -pub use fabro_sandbox::{DockerSandboxOptions, docker_sandbox}; +pub use fabro_sandbox::{ProviderAccess, SandboxOptions, SandboxProviderKind, provider_sandbox}; pub use fabro_types::SteeringMessage; pub use history::History; pub use local_sandbox::{DriverSandbox, local_sandbox}; diff --git a/lib/components/fabro-agent/tests/it/docker_shell.rs b/lib/components/fabro-agent/tests/it/docker_shell.rs index dc0c9502d..5c747207a 100644 --- a/lib/components/fabro-agent/tests/it/docker_shell.rs +++ b/lib/components/fabro-agent/tests/it/docker_shell.rs @@ -9,7 +9,7 @@ use fabro_agent::sandbox::Sandbox; use fabro_agent::tool_registry::ToolContext; use fabro_agent::tools::make_shell_tool; use fabro_agent::types::AgentEvent; -use fabro_agent::{DockerSandboxOptions, Emitter, docker_sandbox}; +use fabro_agent::{Emitter, ProviderAccess, SandboxOptions, SandboxProviderKind, provider_sandbox}; use fabro_types::CommandTermination; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; @@ -17,12 +17,13 @@ use tokio_util::sync::CancellationToken; #[tokio::test] #[ignore = "requires real Docker container lifecycle; run explicitly when changing shell tool exec integration"] async fn shell_reports_real_docker_process_outcome() { - let Ok(sandbox) = docker_sandbox( - DockerSandboxOptions { - image: "buildpack-deps:noble".to_string(), - auto_pull: false, + let Ok(sandbox) = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some("buildpack-deps:noble".to_string()), skip_clone: true, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, None, None, diff --git a/lib/components/fabro-sandbox/src/config.rs b/lib/components/fabro-sandbox/src/config.rs deleted file mode 100644 index ec946a537..000000000 --- a/lib/components/fabro-sandbox/src/config.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Sandbox configuration runtime types. -//! -//! These types are the runtime shape that the sandbox providers consume. -//! -//! The `DaytonaSettings`/`DaytonaSnapshotSettings` names are kept for -//! backward compatibility with the old import path; [`crate::daytona`] -//! continues to re-export them under `DaytonaConfig`/`DaytonaSnapshotConfig` -//! aliases. - -use std::collections::HashMap; - -use serde::de::{self, MapAccess, Visitor}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct DaytonaSettings { - pub auto_stop_interval: Option, - pub labels: Option>, - pub snapshot: Option, - pub network: Option, - /// Git history depth for the repository clone; `None` clones full - /// history. - pub clone_depth: Option, - #[serde(default)] - pub skip_clone: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum DaytonaNetwork { - Block, - AllowAll, - AllowList(Vec), -} - -impl Serialize for DaytonaNetwork { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - Self::Block => serializer.serialize_str("block"), - Self::AllowAll => serializer.serialize_str("allow_all"), - Self::AllowList(cidrs) => { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("allow_list", cidrs)?; - map.end() - } - } - } -} - -impl<'de> Deserialize<'de> for DaytonaNetwork { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct DaytonaNetworkVisitor; - - impl<'de> Visitor<'de> for DaytonaNetworkVisitor { - type Value = DaytonaNetwork; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - formatter, - r#""block", "allow_all", or {{ allow_list = [...] }}"# - ) - } - - fn visit_str(self, value: &str) -> Result { - match value { - "block" => Ok(DaytonaNetwork::Block), - "allow_all" => Ok(DaytonaNetwork::AllowAll), - other => Err(de::Error::custom(format!( - "unknown network mode \"{other}\": expected \"block\" or \"allow_all\"" - ))), - } - } - - fn visit_map>(self, mut map: M) -> Result { - let Some(key) = map.next_key::()? else { - return Err(de::Error::custom( - "empty table: expected { allow_list = [...] }", - )); - }; - - if key != "allow_list" { - return Err(de::Error::custom(format!( - "unknown key \"{key}\": expected \"allow_list\"" - ))); - } - - let cidrs: Vec = map.next_value()?; - - if cidrs.is_empty() { - return Err(de::Error::custom("allow_list must not be empty")); - } - - if let Some(extra) = map.next_key::()? { - return Err(de::Error::custom(format!( - "unexpected key \"{extra}\": allow_list table must have exactly one key" - ))); - } - - Ok(DaytonaNetwork::AllowList(cidrs)) - } - } - - deserializer.deserialize_any(DaytonaNetworkVisitor) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum DockerfileSource { - Inline(String), - Path { path: String }, -} - -/// Where a custom Daytona snapshot is built from. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub enum DaytonaSnapshotSource { - /// A pullable image reference such as `ubuntu:24.04`. - Image(String), - /// A Dockerfile that Daytona builds into the snapshot. - Dockerfile(DockerfileSource), -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct DaytonaSnapshotSettings { - pub cpu: Option, - pub memory: Option, - pub disk: Option, - pub source: DaytonaSnapshotSource, -} diff --git a/lib/components/fabro-sandbox/src/daytona.rs b/lib/components/fabro-sandbox/src/daytona.rs index 54a2e0173..2637ec330 100644 --- a/lib/components/fabro-sandbox/src/daytona.rs +++ b/lib/components/fabro-sandbox/src/daytona.rs @@ -1,37 +1,31 @@ -//! The `daytona` provider kind: fabro's environment mapping onto the +//! The `daytona` provider kind: what fabro adds to a run's spec for the //! sandbox-driver Daytona provider. //! -//! Fabro decides the snapshot (built from the environment's image or -//! Dockerfile and named by an HMAC of its inputs), the lifecycle timers, -//! labels, network policy, and workspace layout; the driver creates and -//! drives the sandbox. The run works in `/home/daytona/workspace`, with a -//! cloned repository checked out under `/home/daytona/repos` and linked into -//! the workspace. +//! The environment's options build the spec once; Daytona's overlay creates +//! sandboxes from a snapshot (built from the environment's image or +//! Dockerfile and named by an HMAC of its inputs, or Daytona's default when +//! the environment names neither), fixes the working directory, and sets +//! the lifecycle timers. The run works in `/home/daytona/workspace`, with a +//! cloned repository checked out under `/home/daytona/repos` and linked +//! into the workspace. use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use fabro_github::GitHubCredentials; use fabro_types::settings::server::ServerSandboxProviderSettings; use fabro_types::{RunId, SandboxProviderKind}; use sandbox_driver::{ - HealthStatus, LifecycleTimers, NetworkPolicy, Resources, SandboxId, SandboxProvider, - SandboxSource, SandboxSpec as DriverSpec, SnapshotFilter, SnapshotId, SnapshotProvider, - SnapshotSource, SnapshotSpec, SnapshotState, + HealthStatus, LifecycleTimers, Resources, SandboxProvider, SandboxSource, + SandboxSpec as DriverSpec, SnapshotFilter, SnapshotId, SnapshotProvider, SnapshotSource, + SnapshotSpec, SnapshotState, }; use tokio::time; -pub use crate::config::{ - DaytonaNetwork, DaytonaSettings as DaytonaConfig, - DaytonaSnapshotSettings as DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource, -}; pub use crate::driver::DaytonaCredentials; use crate::driver::{ProviderConnectOptions, connect_provider}; -use crate::driver_sandbox::{ - CreatePlan, DriverSandbox, LayoutSource, PreparedCreate, RepoWorkspace, WorkspaceLayout, -}; -use crate::managed_labels; +use crate::driver_sandbox::{CreatePlan, PreparedCreate, WorkspaceLayout}; +use crate::options::SandboxOptions; use crate::sandbox::SandboxEvent; pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; @@ -46,8 +40,8 @@ const DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT: Duration = Duration::from_mins(30); /// would inherit Daytona's server-side default of 15 idle minutes, which is /// shorter than a single long inference call and stops the sandbox mid-run; /// 120 minutes clears any realistic call while still reclaiming sandboxes -/// leaked by a dead worker. An explicit `0` disables auto-stop entirely. -const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120; +/// leaked by a dead worker. An explicit zero disables auto-stop entirely. +const DEFAULT_AUTO_STOP: Duration = Duration::from_hours(2); /// Scopes a Daytona API key needs for fabro's snapshot and sandbox flow, in /// the order the remediation text lists them. @@ -58,13 +52,53 @@ pub const REQUIRED_DAYTONA_SCOPES: &[&str] = &[ "delete:sandboxes", ]; +/// What a custom snapshot is built from: the environment's image or +/// Dockerfile and its resources in whole gigabytes, the units Daytona +/// sizes snapshots in and the values the snapshot's name is derived from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SnapshotInputs<'a> { + pub source: SnapshotInput<'a>, + pub cpu: Option, + pub memory_gb: Option, + pub disk_gb: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SnapshotInput<'a> { + /// A pullable image reference such as `ubuntu:24.04`. + Image(&'a str), + /// A Dockerfile Daytona builds into the snapshot. + Dockerfile(&'a str), +} + +/// The snapshot `options` ask for, or `None` when the environment names no +/// image or Dockerfile and the sandbox comes from Daytona's default. +pub fn snapshot_inputs(options: &SandboxOptions) -> Option> { + let source = match (&options.image, &options.dockerfile) { + (Some(image), _) => SnapshotInput::Image(image), + (None, Some(dockerfile)) => SnapshotInput::Dockerfile(dockerfile), + (None, None) => return None, + }; + Some(SnapshotInputs { + source, + cpu: options.cpu.and_then(|cpu| i32::try_from(cpu).ok()), + memory_gb: options.memory_bytes.map(bytes_to_gb), + disk_gb: options.disk_bytes.map(bytes_to_gb), + }) +} + +/// Whole decimal gigabytes, the unit Daytona sizes snapshots in. +fn bytes_to_gb(bytes: u64) -> i32 { + i32::try_from(bytes / 1_000_000_000).unwrap_or(i32::MAX) +} + pub mod snapshot_identity { use hmac::{Hmac, Mac}; use serde::Serialize; use sha2::{Digest, Sha256}; use uuid::Uuid; - use super::{DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource}; + use super::{SnapshotInput, SnapshotInputs}; const IDENTITY_VERSION: u8 = 1; const PROVIDER: &str = "daytona"; @@ -96,12 +130,12 @@ pub mod snapshot_identity { entrypoint: Option<&'static str>, } - /// The name of the snapshot built from `config`: a UUIDv8 derived from an + /// The name of the snapshot built from `inputs`: a UUIDv8 derived from an /// HMAC of the build inputs keyed by the API key, so the same inputs reuse /// the same snapshot and a rotated key never collides with another /// tenant's. - pub fn snapshot_name(api_key: &str, config: &DaytonaSnapshotConfig) -> crate::Result { - let manifest = canonical_manifest(config)?; + pub fn snapshot_name(api_key: &str, inputs: &SnapshotInputs<'_>) -> crate::Result { + let manifest = canonical_manifest(inputs)?; let mut mac = HmacSha256::new_from_slice(api_key.as_bytes()) .expect("HMAC-SHA256 accepts keys of any length"); mac.update(&manifest); @@ -111,26 +145,21 @@ pub mod snapshot_identity { Ok(format!("fabro-{}", Uuid::new_v8(bytes))) } - fn canonical_manifest(config: &DaytonaSnapshotConfig) -> crate::Result> { - let source = match &config.source { - DaytonaSnapshotSource::Image(image) => SourceManifest::Image(image), - DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(text)) => { + fn canonical_manifest(inputs: &SnapshotInputs<'_>) -> crate::Result> { + let source = match inputs.source { + SnapshotInput::Image(image) => SourceManifest::Image(image), + SnapshotInput::Dockerfile(text) => { SourceManifest::DockerfileSha256(hex::encode(Sha256::digest(text.as_bytes()))) } - DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { .. }) => { - return Err(crate::Error::message( - "Daytona snapshot dockerfile path should have been resolved to inline content before sandbox creation", - )); - } }; let manifest = SnapshotManifest { identity_version: IDENTITY_VERSION, provider: PROVIDER, tenant: TENANT, source, - cpu: config.cpu, - memory_gb: config.memory, - disk_gb: config.disk, + cpu: inputs.cpu, + memory_gb: inputs.memory_gb, + disk_gb: inputs.disk_gb, entrypoint: None, }; serde_json::to_vec(&manifest).map_err(|err| { @@ -273,63 +302,40 @@ pub(crate) fn layout() -> WorkspaceLayout { } } -/// The driver spec for a fabro Daytona sandbox created from `snapshot`. -pub(crate) fn driver_spec( - config: &DaytonaConfig, +/// Daytona's additions to the base spec: the snapshot the sandbox is created +/// from, the fixed working directory, the run's Daytona name, and the +/// lifecycle timers. +pub(crate) fn overlay( + spec: DriverSpec, + options: &SandboxOptions, run_id: Option<&RunId>, snapshot: &SnapshotId, ) -> DriverSpec { - let mut spec = DriverSpec::new(SandboxSource::Snapshot { + let mut spec = spec.working_directory(WORKING_DIRECTORY); + spec.source = SandboxSource::Snapshot { id: snapshot.clone(), - }) - .working_directory(WORKING_DIRECTORY) - .network(match &config.network { - Some(DaytonaNetwork::Block) => NetworkPolicy::Block, - Some(DaytonaNetwork::AllowAll) => NetworkPolicy::AllowAll, - Some(DaytonaNetwork::AllowList(cidrs)) => NetworkPolicy::CidrAllowList { - cidrs: cidrs.clone(), - }, - None => NetworkPolicy::ProviderDefault, - }); - if let Some(run_id) = run_id { - spec = spec.name(format!("fabro-{run_id}")); - } - let mut labels: Vec<(String, String)> = - managed_labels::merge_for_run(config.labels.as_ref(), run_id) - .into_iter() - .collect(); - labels.sort(); - for (key, value) in labels { - spec = spec.label(key, value); - } + }; + spec.name = run_id.map(|run_id| format!("fabro-{run_id}")); let mut timers = LifecycleTimers::default(); // An explicit zero disables auto-stop; the driver encodes // `Duration::ZERO` as that wire value. - timers.auto_stop_after_idle = Some(minutes_to_duration( - config - .auto_stop_interval - .unwrap_or(DEFAULT_AUTO_STOP_INTERVAL_MINUTES), - )); + timers.auto_stop_after_idle = Some(options.auto_stop.unwrap_or(DEFAULT_AUTO_STOP)); // Run sandboxes are never deleted on stop: the run record may need // them again on resume, and `fabro system prune` reclaims them. timers.auto_delete_after_stop = Some(Duration::ZERO); spec.timers(timers) } -fn minutes_to_duration(minutes: i32) -> Duration { - Duration::from_mins(u64::try_from(minutes).unwrap_or(0)) -} - -/// Ensures the snapshot `config` describes exists and is active, building +/// Ensures the snapshot `inputs` describe exists and is active, building /// it when Daytona does not have it. Returns the snapshot to create /// sandboxes from. async fn ensure_snapshot( provider: &dyn SandboxProvider, api_key: &str, - config: &DaytonaSnapshotConfig, + inputs: &SnapshotInputs<'_>, emit: &(dyn Fn(SandboxEvent) + Send + Sync), ) -> crate::Result<(SnapshotId, String)> { - let name = snapshot_identity::snapshot_name(api_key, config)?; + let name = snapshot_identity::snapshot_name(api_key, inputs)?; let snapshots = provider.snapshots().ok_or_else(|| { crate::Error::message("The Daytona provider does not expose snapshot management") })?; @@ -372,7 +378,7 @@ async fn ensure_snapshot( } } else { emit(SandboxEvent::SnapshotCreating { name: name.clone() }); - let spec = snapshot_spec(&name, config)?; + let spec = snapshot_spec(&name, inputs); snapshots.create(&spec, None).await.map_err(|error| { crate::Error::context(format!("Failed to create snapshot '{name}'"), error) })? @@ -381,33 +387,26 @@ async fn ensure_snapshot( Ok((id, name)) } -fn snapshot_spec(name: &str, config: &DaytonaSnapshotConfig) -> crate::Result { - let source = match &config.source { - DaytonaSnapshotSource::Image(image) => SnapshotSource::Image { - reference: image.clone(), +fn snapshot_spec(name: &str, inputs: &SnapshotInputs<'_>) -> SnapshotSpec { + let source = match inputs.source { + SnapshotInput::Image(image) => SnapshotSource::Image { + reference: image.to_string(), + }, + SnapshotInput::Dockerfile(content) => SnapshotSource::Dockerfile { + content: content.to_string(), }, - DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(content)) => { - SnapshotSource::Dockerfile { - content: content.clone(), - } - } - DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { .. }) => { - return Err(crate::Error::message(format!( - "Snapshot '{name}': dockerfile path should have been resolved to inline content before sandbox creation" - ))); - } }; let mut resources = Resources::default(); - resources.cpu_cores = config.cpu.and_then(|cpu| u32::try_from(cpu).ok()); - resources.memory_mb = config - .memory + resources.cpu_cores = inputs.cpu.and_then(|cpu| u32::try_from(cpu).ok()); + resources.memory_mb = inputs + .memory_gb .and_then(|gb| u64::try_from(gb).ok()) .map(|gb| gb * 1024); - resources.disk_mb = config - .disk + resources.disk_mb = inputs + .disk_gb .and_then(|gb| u64::try_from(gb).ok()) .map(|gb| gb * 1024); - Ok(SnapshotSpec::new(source).name(name).resources(resources)) + SnapshotSpec::new(source).name(name).resources(resources) } /// Polls a snapshot until it is active, with exponential back-off, or fails @@ -442,24 +441,44 @@ async fn wait_for_active_snapshot( } /// Prepares a Daytona create: the snapshot first, then the spec naming it. -struct DaytonaCreatePlan { +pub(crate) struct DaytonaCreatePlan { provider: Arc, api_key: String, - config: DaytonaConfig, + base: DriverSpec, + options: SandboxOptions, run_id: Option, } +/// The create plan for a run on Daytona: `base` is the spec the +/// environment's options built, which the plan completes with the snapshot +/// once it exists. +pub(crate) fn create_plan( + provider: Arc, + api_key: String, + base: DriverSpec, + options: SandboxOptions, + run_id: Option, +) -> DaytonaCreatePlan { + DaytonaCreatePlan { + provider, + api_key, + base, + options, + run_id, + } +} + #[async_trait] impl CreatePlan for DaytonaCreatePlan { async fn prepare( &self, emit: &(dyn Fn(SandboxEvent) + Send + Sync), ) -> crate::Result { - let (snapshot_id, snapshot_name) = match &self.config.snapshot { - Some(snapshot) => { + let (snapshot_id, snapshot_name) = match snapshot_inputs(&self.options) { + Some(inputs) => { let started = time::Instant::now(); let result = - ensure_snapshot(self.provider.as_ref(), &self.api_key, snapshot, emit).await; + ensure_snapshot(self.provider.as_ref(), &self.api_key, &inputs, emit).await; match result { Ok((id, name)) => { emit(SandboxEvent::SnapshotReady { @@ -470,7 +489,7 @@ impl CreatePlan for DaytonaCreatePlan { (id, name) } Err(error) => { - let name = snapshot_identity::snapshot_name(&self.api_key, snapshot) + let name = snapshot_identity::snapshot_name(&self.api_key, &inputs) .unwrap_or_default(); emit(SandboxEvent::SnapshotFailed { name, @@ -487,134 +506,87 @@ impl CreatePlan for DaytonaCreatePlan { ), }; Ok(PreparedCreate { - spec: driver_spec(&self.config, self.run_id.as_ref(), &snapshot_id), + spec: overlay( + self.base.clone(), + &self.options, + self.run_id.as_ref(), + &snapshot_id, + ), source: Some(snapshot_name.clone()), snapshot: Some(snapshot_name), }) } } -/// A Daytona sandbox for a run. The sandbox is created by `initialize`; -/// construction validates the clone request and connects the provider, so -/// a bad spec or missing credential fails before any control-plane call. -#[expect( - clippy::too_many_arguments, - reason = "mirrors SandboxSpec::Daytona; clone inputs are validated together" -)] -pub async fn daytona_sandbox( - config: DaytonaConfig, - github_app: Option<&GitHubCredentials>, - run_id: Option, - clone_origin_url: Option, - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, - credentials: &DaytonaCredentials, -) -> crate::Result { - let workspace = RepoWorkspace::plan( - LayoutSource::Fixed(layout()), - config.skip_clone, - clone_origin_url.as_deref(), - clone_branch.as_deref(), - clone_tag.as_deref(), - clone_commit_sha.as_deref(), - config - .clone_depth - .and_then(|depth| u32::try_from(depth).ok()), - github_app, - )?; - let provider = connect(credentials) - .await - .map_err(|error| crate::Error::context_anyhow("Failed to connect to Daytona", error))?; - let plan = DaytonaCreatePlan { - provider: Arc::clone(&provider), - api_key: credentials.api_key.clone(), - config, - run_id, - }; - Ok(DriverSandbox::pending_with_plan( - SandboxProviderKind::DAYTONA, - provider, - Box::new(plan), - workspace, - )) -} - -/// Reattach to a run's Daytona sandbox by its persisted id. -/// -/// The sandbox must carry fabro's managed label and, when a run id is -/// known, the matching run label: fabro never operates on a sandbox it did -/// not create, even inside its own organization. -pub async fn attach_daytona( - sandbox_id: &str, - repo_cloned: bool, - working_directory: String, - clone_origin_url: Option, - run_id: Option, - credentials: &DaytonaCredentials, -) -> crate::Result { - let provider = connect(credentials) - .await - .map_err(|error| crate::Error::context_anyhow("Failed to connect to Daytona", error))?; - let id = SandboxId::try_new(sandbox_id) - .map_err(|error| crate::Error::context("Invalid Daytona sandbox id", error))?; - let handle = provider.attach(&id, None).await.map_err(|error| { - crate::Error::context( - format!("Failed to reconnect Daytona sandbox '{sandbox_id}'"), - error, - ) - })?; - let status = handle.describe().await?; - managed_labels::verify_managed( - &SandboxProviderKind::DAYTONA, - sandbox_id, - &status.labels, - run_id.as_ref(), - )?; - 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); - } - Ok(sandbox) -} - #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::collections::BTreeMap; + + use sandbox_driver::NetworkPolicy; use super::*; + use crate::options::base_spec; fn run_id() -> RunId { "01HY0000000000000000000000".parse().unwrap() } - #[test] - fn daytona_config_defaults() { - let config = DaytonaConfig::default(); - assert!(config.snapshot.is_none()); - assert!(config.auto_stop_interval.is_none()); - assert!(config.labels.is_none()); - assert!(config.clone_depth.is_none()); + fn dockerfile_inputs(dockerfile: &str) -> SnapshotInputs<'_> { + SnapshotInputs { + source: SnapshotInput::Dockerfile(dockerfile), + cpu: Some(2), + memory_gb: Some(4), + disk_gb: Some(10), + } } #[test] - fn driver_spec_names_the_run_and_carries_fabro_labels_and_timers() { - let config = DaytonaConfig { - labels: Some(HashMap::from([( - "team".to_string(), - "platform".to_string(), - )])), - network: Some(DaytonaNetwork::AllowList(vec!["10.0.0.0/8".to_string()])), - ..DaytonaConfig::default() + fn snapshot_inputs_come_from_the_image_or_dockerfile_in_whole_gigabytes() { + assert!(snapshot_inputs(&SandboxOptions::default()).is_none()); + + let options = SandboxOptions { + image: Some("ubuntu:24.04".to_string()), + cpu: Some(2), + memory_bytes: Some(4_000_000_000), + disk_bytes: Some(10_500_000_000), + ..SandboxOptions::default() + }; + assert_eq!( + snapshot_inputs(&options), + Some(SnapshotInputs { + source: SnapshotInput::Image("ubuntu:24.04"), + cpu: Some(2), + memory_gb: Some(4), + disk_gb: Some(10), + }) + ); + + let options = SandboxOptions { + dockerfile: Some("FROM ubuntu".to_string()), + ..SandboxOptions::default() + }; + assert_eq!( + snapshot_inputs(&options).map(|inputs| inputs.source), + Some(SnapshotInput::Dockerfile("FROM ubuntu")) + ); + } + + #[test] + fn overlay_names_the_run_and_carries_fabro_labels_and_timers() { + let options = SandboxOptions { + labels: BTreeMap::from([("team".to_string(), "platform".to_string())]), + network: NetworkPolicy::CidrAllowList { + cidrs: vec!["10.0.0.0/8".to_string()], + }, + ..SandboxOptions::default() }; let snapshot = SnapshotId::try_new("snap-1").unwrap(); - let spec = driver_spec(&config, Some(&run_id()), &snapshot); + let spec = overlay( + base_spec(&options, Some(&run_id())), + &options, + Some(&run_id()), + &snapshot, + ); assert!(matches!(&spec.source, SandboxSource::Snapshot { id } if id == &snapshot)); assert_eq!( @@ -648,17 +620,14 @@ mod tests { } #[test] - fn driver_spec_passes_explicit_auto_stop_through_and_zero_disables() { + fn overlay_passes_explicit_auto_stop_through_and_zero_disables() { let snapshot = SnapshotId::try_new(DEFAULT_SNAPSHOT).unwrap(); - let explicit = driver_spec( - &DaytonaConfig { - auto_stop_interval: Some(45), - network: Some(DaytonaNetwork::Block), - ..DaytonaConfig::default() - }, - None, - &snapshot, - ); + let options = SandboxOptions { + auto_stop: Some(Duration::from_mins(45)), + network: NetworkPolicy::Block, + ..SandboxOptions::default() + }; + let explicit = overlay(base_spec(&options, None), &options, None, &snapshot); assert_eq!( explicit.timers.auto_stop_after_idle, Some(Duration::from_mins(45)) @@ -667,26 +636,23 @@ mod tests { assert!(explicit.name.is_none()); assert!(!explicit.labels.contains_key("sh.fabro.run_id")); - let disabled = driver_spec( - &DaytonaConfig { - auto_stop_interval: Some(0), - ..DaytonaConfig::default() - }, - None, - &snapshot, - ); + let options = SandboxOptions { + auto_stop: Some(Duration::ZERO), + ..SandboxOptions::default() + }; + let disabled = overlay(base_spec(&options, None), &options, None, &snapshot); assert_eq!(disabled.timers.auto_stop_after_idle, Some(Duration::ZERO)); } #[test] fn snapshot_spec_maps_sources_and_gigabyte_resources() { - let config = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()), + let inputs = SnapshotInputs { + source: SnapshotInput::Image("ubuntu:24.04"), + cpu: Some(2), + memory_gb: Some(4), + disk_gb: Some(10), }; - let spec = snapshot_spec("fabro-x", &config).unwrap(); + let spec = snapshot_spec("fabro-x", &inputs); assert_eq!(spec.name.as_deref(), Some("fabro-x")); assert!(matches!( &spec.source, @@ -696,46 +662,23 @@ mod tests { assert_eq!(spec.resources.memory_mb, Some(4096)); assert_eq!(spec.resources.disk_mb, Some(10_240)); - let dockerfile = snapshot_spec("fabro-y", &DaytonaSnapshotConfig { - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM ubuntu".to_string(), - )), - ..config.clone() - }) - .unwrap(); + let dockerfile = snapshot_spec("fabro-y", &SnapshotInputs { + source: SnapshotInput::Dockerfile("FROM ubuntu"), + ..inputs + }); assert!(matches!( &dockerfile.source, SnapshotSource::Dockerfile { content } if content == "FROM ubuntu" )); - - let unresolved = snapshot_spec("fabro-z", &DaytonaSnapshotConfig { - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { - path: "Dockerfile".to_string(), - }), - ..config - }) - .unwrap_err(); - assert!( - unresolved - .to_string() - .contains("resolved to inline content") - ); } #[test] fn computed_snapshot_identity_is_deterministic_and_keyed() { - let config = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM ubuntu:24.04\nRUN apt-get update".to_string(), - )), - }; + let inputs = dockerfile_inputs("FROM ubuntu:24.04\nRUN apt-get update"); - let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); - let second = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); - let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &config).unwrap(); + let first = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap(); + let second = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap(); + let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &inputs).unwrap(); assert_eq!(first, second); assert_eq!(first, "fabro-e607185f-c7ab-88c9-bf9d-d70addba9298"); @@ -750,33 +693,24 @@ mod tests { #[test] fn computed_snapshot_identity_changes_for_generation_inputs() { - let base = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM ubuntu:24.04".to_string(), - )), - }; + let base = dockerfile_inputs("FROM ubuntu:24.04"); let base_name = snapshot_identity::snapshot_name("dtn_secret", &base).unwrap(); let cases = [ - DaytonaSnapshotConfig { - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM ubuntu:24.04\n# roll cache".to_string(), - )), + SnapshotInputs { + source: SnapshotInput::Dockerfile("FROM ubuntu:24.04\n# roll cache"), ..base.clone() }, - DaytonaSnapshotConfig { + SnapshotInputs { cpu: Some(4), ..base.clone() }, - DaytonaSnapshotConfig { - memory: Some(8), + SnapshotInputs { + memory_gb: Some(8), ..base.clone() }, - DaytonaSnapshotConfig { - disk: Some(20), + SnapshotInputs { + disk_gb: Some(20), ..base.clone() }, ]; @@ -789,16 +723,16 @@ mod tests { #[test] fn computed_snapshot_identity_excludes_raw_dockerfile_and_key_material() { - let config = DaytonaSnapshotConfig { - cpu: None, - memory: None, - disk: None, - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM private.example.com/secret-image\nRUN echo raw-secret".to_string(), - )), + let inputs = SnapshotInputs { + source: SnapshotInput::Dockerfile( + "FROM private.example.com/secret-image\nRUN echo raw-secret", + ), + cpu: None, + memory_gb: None, + disk_gb: None, }; - let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &config).unwrap(); + let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &inputs).unwrap(); assert!(name.starts_with("fabro-")); assert!(!name.contains("private.example.com")); @@ -808,16 +742,16 @@ mod tests { #[test] fn computed_snapshot_identity_changes_for_image_reference() { - let config = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()), + let inputs = SnapshotInputs { + source: SnapshotInput::Image("ubuntu:24.04"), + cpu: Some(2), + memory_gb: Some(4), + disk_gb: Some(10), }; - let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); - let changed = snapshot_identity::snapshot_name("dtn_secret", &DaytonaSnapshotConfig { - source: DaytonaSnapshotSource::Image("ubuntu:24.10".to_string()), - ..config + let first = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap(); + let changed = snapshot_identity::snapshot_name("dtn_secret", &SnapshotInputs { + source: SnapshotInput::Image("ubuntu:24.10"), + ..inputs }) .unwrap(); @@ -886,13 +820,14 @@ mod wire_gate { use fabro_static::EnvVars; use fabro_types::SandboxProviderKind; - use sandbox_driver::{SandboxProvider, SandboxSource, SandboxSpec as DriverSpec}; + use sandbox_driver::SandboxProvider; use sandbox_driver_protocol::{PluginProvider, serve}; use tokio::io::{duplex, split}; use super::*; use crate::Sandbox as _; use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace}; + use crate::options::base_spec; #[expect( clippy::disallowed_methods, @@ -939,8 +874,8 @@ mod wire_gate { ) .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 options = SandboxOptions::default(); + let spec = overlay(base_spec(&options, None), &options, None, &snapshot); let sandbox = DriverSandbox::pending( SandboxProviderKind::DAYTONA, remote, @@ -981,11 +916,4 @@ mod wire_gate { 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"), - } - } } diff --git a/lib/components/fabro-sandbox/src/docker.rs b/lib/components/fabro-sandbox/src/docker.rs index 85e96f750..45e634d3a 100644 --- a/lib/components/fabro-sandbox/src/docker.rs +++ b/lib/components/fabro-sandbox/src/docker.rs @@ -1,67 +1,25 @@ -//! The `docker` provider kind: fabro's environment mapping onto the +//! The `docker` provider kind: what fabro adds to a run's spec for the //! sandbox-driver Docker provider. //! -//! Fabro decides the image, resources, network policy, environment, labels, -//! and workspace layout; the driver creates and drives the container. The -//! container's working directory is [`WORKING_DIRECTORY`]; a cloned -//! repository checks out under [`REPOS_ROOT`] and is linked into the -//! workspace, so the run works in `/workspace/`. +//! The environment's options build the spec once; Docker's overlay fixes the +//! container's working directory at [`WORKING_DIRECTORY`], supplies the +//! default image when the environment names none, and asks the provider to +//! pull a missing image. A cloned repository checks out under +//! [`REPOS_ROOT`] and is linked into the workspace, so the run works in +//! `/workspace/`. -use fabro_github::GitHubCredentials; -use fabro_types::settings::run::RunCloneSettings; -use fabro_types::settings::server::ServerSandboxProviderSettings; -use fabro_types::{RunId, SandboxProviderKind}; -use sandbox_driver::{ - HealthStatus, NetworkPolicy, Resources, SandboxId, SandboxSource, SandboxSpec as DriverSpec, -}; +use sandbox_driver::{HealthStatus, SandboxSource, SandboxSpec as DriverSpec}; use sandbox_driver_docker_config::DockerProviderConfig; -use crate::driver::{ProviderConnectOptions, connect_provider}; -use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace, WorkspaceLayout}; -use crate::managed_labels; +use crate::driver::ProviderAccess; +use crate::driver_sandbox::WorkspaceLayout; +use crate::options::SandboxOptions; +use crate::provider_sandbox; pub const WORKING_DIRECTORY: &str = "/workspace"; pub const REPOS_ROOT: &str = "/repos"; -const DEFAULT_GIT_CLONE_DEPTH: usize = RunCloneSettings::DEFAULT_DEPTH.unsigned_abs() as usize; -/// Docker's default CFS period; a whole core is one period's worth of quota. -const CPU_PERIOD_MICROS: i64 = 100_000; - -/// Options fabro derives from an environment for a Docker sandbox. -#[derive(Clone, Debug, PartialEq)] -pub struct DockerSandboxOptions { - /// Docker image to use. - pub image: String, - /// Docker network mode. Default: `Some("bridge")`; `Some("none")` blocks. - pub network_mode: Option, - /// Memory limit in bytes. `None` = unlimited. - pub memory_limit: Option, - /// CPU quota (microseconds per 100ms period). `None` = unlimited. - pub cpu_quota: Option, - /// Whether to pull the image if not found locally. Default: `true`. - pub auto_pull: bool, - /// Additional `KEY=VALUE` environment variables for the container. - pub env_vars: Vec, - /// Maximum Git history depth fetched during clone; `None` fetches full - /// history. - pub clone_depth: Option, - /// Create an empty workspace instead of cloning even when an origin exists. - pub skip_clone: bool, -} - -impl Default for DockerSandboxOptions { - fn default() -> Self { - Self { - image: "buildpack-deps:noble".to_string(), - network_mode: Some("bridge".to_string()), - memory_limit: None, - cpu_quota: None, - auto_pull: true, - env_vars: Vec::new(), - clone_depth: Some(DEFAULT_GIT_CLONE_DEPTH), - skip_clone: false, - } - } -} +/// The image a Docker environment gets when it names none. +pub const DEFAULT_IMAGE: &str = "buildpack-deps:noble"; /// The workspace layout every Docker sandbox uses. pub(crate) fn layout() -> WorkspaceLayout { @@ -71,142 +29,34 @@ pub(crate) fn layout() -> WorkspaceLayout { } } -pub(crate) fn container_name(run_id: &RunId) -> String { - format!("fabro-run-{run_id}") +/// The image a Docker sandbox runs: the environment's, or the default. +pub(crate) fn effective_image(options: &SandboxOptions) -> String { + options + .image + .clone() + .unwrap_or_else(|| DEFAULT_IMAGE.to_string()) } -/// The driver spec for a fabro Docker sandbox. -pub(crate) fn driver_spec(options: &DockerSandboxOptions, run_id: Option<&RunId>) -> DriverSpec { - let mut spec = DriverSpec::new(SandboxSource::Image { - reference: options.image.clone(), - }) - .working_directory(WORKING_DIRECTORY) - .network(match options.network_mode.as_deref() { - Some("none") => NetworkPolicy::Block, - _ => NetworkPolicy::AllowAll, - }) - .provider_config( +/// Docker's additions to the base spec, and the image it will run. +pub(crate) fn overlay(spec: DriverSpec, options: &SandboxOptions) -> (DriverSpec, String) { + let image = effective_image(options); + let mut spec = spec; + spec.source = SandboxSource::Image { + reference: image.clone(), + }; + let spec = spec.working_directory(WORKING_DIRECTORY).provider_config( DockerProviderConfig { - auto_pull: options.auto_pull, + auto_pull: true, ..DockerProviderConfig::default() } .into_value(), ); - if let Some(run_id) = run_id { - spec = spec.name(container_name(run_id)); - } - for (key, value) in managed_labels::for_run(run_id) { - spec = spec.label(key, value); - } - for entry in &options.env_vars { - let (key, value) = entry.split_once('=').unwrap_or((entry.as_str(), "")); - spec = spec.env_var(key, value); - } - let mut resources = Resources::default(); - resources.cpu_cores = options - .cpu_quota - .filter(|quota| *quota > 0) - .map(|quota| (quota + CPU_PERIOD_MICROS - 1) / CPU_PERIOD_MICROS) - .and_then(|cores| u32::try_from(cores).ok()); - resources.memory_mb = options - .memory_limit - .filter(|bytes| *bytes > 0) - .and_then(|bytes| u64::try_from(bytes).ok()) - .map(|bytes| bytes.div_ceil(1024 * 1024)); - spec.resources(resources) -} - -async fn connect_docker() -> crate::Result> { - connect_provider( - &SandboxProviderKind::DOCKER, - &ServerSandboxProviderSettings::default(), - &ProviderConnectOptions::default(), - ) - .await - .map(|connected| connected.provider) - .map_err(|error| crate::Error::context("Failed to connect to the Docker provider", error)) -} - -/// A Docker sandbox for a run. The container is created by `initialize`; -/// construction validates the clone request and connects the provider, so a -/// bad spec fails before any daemon call. -pub async fn docker_sandbox( - options: DockerSandboxOptions, - github_app: Option<&GitHubCredentials>, - run_id: Option, - clone_origin_url: Option, - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, -) -> crate::Result { - let workspace = RepoWorkspace::plan( - LayoutSource::Fixed(layout()), - options.skip_clone, - clone_origin_url.as_deref(), - clone_branch.as_deref(), - clone_tag.as_deref(), - clone_commit_sha.as_deref(), - options - .clone_depth - .and_then(|depth| u32::try_from(depth).ok()), - github_app, - )?; - let provider = connect_docker().await?; - let spec = driver_spec(&options, run_id.as_ref()); - Ok(DriverSandbox::pending( - SandboxProviderKind::DOCKER, - provider, - spec, - Some(options.image), - workspace, - )) -} - -/// Reattach to a run's Docker container by its persisted id. -/// -/// The container must carry fabro's managed label and, when a run id is -/// known, the matching run label: the driver shares a daemon with every -/// other application, and fabro never operates on a container it did not -/// create. -pub async fn attach_docker( - container_id: &str, - repo_cloned: bool, - working_directory: String, - clone_origin_url: Option, - run_id: Option, -) -> crate::Result { - let provider = connect_docker().await?; - let id = SandboxId::try_new(container_id) - .map_err(|error| crate::Error::context("Invalid Docker container id", error))?; - let handle = provider.attach(&id, None).await.map_err(|error| { - crate::Error::context( - format!("Failed to reconnect Docker container '{container_id}'"), - error, - ) - })?; - let status = handle.describe().await?; - managed_labels::verify_managed( - &SandboxProviderKind::DOCKER, - container_id, - &status.labels, - run_id.as_ref(), - )?; - let workspace = RepoWorkspace::attached( - LayoutSource::Fixed(layout()), - repo_cloned, - working_directory, - clone_origin_url, - ); - Ok(DriverSandbox::attached( - SandboxProviderKind::DOCKER, - handle, - workspace, - )) + (spec, image) } /// Whether the Docker daemon answers. Used by `fabro doctor`. pub async fn check_docker_daemon() -> crate::Result<()> { - let provider = connect_docker().await?; + let provider = provider_sandbox::connect_bundled_docker(&ProviderAccess::default()).await?; let health = provider .health() .await @@ -226,27 +76,31 @@ pub async fn check_docker_daemon() -> crate::Result<()> { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + + use fabro_types::RunId; + use sandbox_driver::NetworkPolicy; + use super::*; + use crate::options::base_spec; #[test] - fn driver_spec_maps_image_workspace_labels_env_and_limits() { + fn overlay_fixes_the_workspace_and_pulls_the_named_image() { let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let options = DockerSandboxOptions { - env_vars: vec![ - "FOO=bar".to_string(), - "BASH_ENV=/tmp/untrusted-startup".to_string(), - ], - memory_limit: Some(4_000_000_000), - cpu_quota: Some(200_000), - network_mode: Some("none".to_string()), - auto_pull: false, - ..DockerSandboxOptions::default() + let options = SandboxOptions { + image: Some("ghcr.io/acme/dev:1".to_string()), + env: BTreeMap::from([("FOO".to_string(), "bar".to_string())]), + memory_bytes: Some(4_000_000_000), + cpu: Some(2), + network: NetworkPolicy::Block, + ..SandboxOptions::default() }; - let spec = driver_spec(&options, Some(&run_id)); + let (spec, image) = overlay(base_spec(&options, Some(&run_id)), &options); + assert_eq!(image, "ghcr.io/acme/dev:1"); assert!(matches!( &spec.source, - SandboxSource::Image { reference } if reference == "buildpack-deps:noble" + SandboxSource::Image { reference } if reference == "ghcr.io/acme/dev:1" )); assert_eq!( spec.name.as_deref(), @@ -262,20 +116,22 @@ mod tests { Some("01HY0000000000000000000000") ); assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar")); - // The driver blanks BASH_ENV on every container; a caller value is - // passed through here and overridden there. assert_eq!(spec.resources.cpu_cores, Some(2)); assert_eq!(spec.resources.memory_mb, Some(3815)); assert!(matches!(spec.network, NetworkPolicy::Block)); - assert_eq!(spec.provider_config["auto_pull"], false); + assert_eq!(spec.provider_config["auto_pull"], true); } #[test] - fn driver_spec_defaults_to_bridge_networking_without_a_name() { - let spec = driver_spec(&DockerSandboxOptions::default(), None); + fn overlay_supplies_the_default_image_when_the_environment_names_none() { + let options = SandboxOptions::default(); + let (spec, image) = overlay(base_spec(&options, None), &options); + assert_eq!(image, DEFAULT_IMAGE); + assert!(matches!( + &spec.source, + SandboxSource::Image { reference } if reference == DEFAULT_IMAGE + )); assert!(spec.name.is_none()); - assert!(matches!(spec.network, NetworkPolicy::AllowAll)); - assert_eq!(spec.resources, Resources::default()); assert!(!spec.labels.contains_key("sh.fabro.run_id")); } } diff --git a/lib/components/fabro-sandbox/src/from_environment.rs b/lib/components/fabro-sandbox/src/from_environment.rs deleted file mode 100644 index 66c4f2ec2..000000000 --- a/lib/components/fabro-sandbox/src/from_environment.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Convert resolved [`RunEnvironmentSettings`] into runtime sandbox configs. -//! -//! These mappings are consumed by both the workflow run-start path and the -//! server preflight path, so they live here next to their destination types. - -use std::path::{Path, PathBuf}; - -use fabro_types::settings::ResolveError; -use fabro_types::settings::run::{ - DockerfileSource as ResolvedDockerfileSource, EnvironmentNetworkMode, RunCloneSettings, - RunEnvironmentSettings, -}; - -use crate::config::{ - DaytonaNetwork, DaytonaSnapshotSettings, DaytonaSnapshotSource, - DockerfileSource as SandboxDockerfileSource, -}; -use crate::daytona::DaytonaConfig; -use crate::docker::DockerSandboxOptions; - -#[must_use] -pub fn daytona_config_from_environment( - settings: &RunEnvironmentSettings, - clone: &RunCloneSettings, -) -> DaytonaConfig { - // fabro-config rejects Daytona environments that set both image.docker - // and image.dockerfile. If both still arrive here, the image wins, which - // matches how the Docker provider treats the pair. - let source = match (&settings.image.docker, &settings.image.dockerfile) { - (Some(image), _) => Some(DaytonaSnapshotSource::Image(image.clone())), - (None, Some(ResolvedDockerfileSource::Inline(text))) => Some( - DaytonaSnapshotSource::Dockerfile(SandboxDockerfileSource::Inline(text.clone())), - ), - (None, Some(ResolvedDockerfileSource::Path { path })) => Some( - DaytonaSnapshotSource::Dockerfile(SandboxDockerfileSource::Path { path: path.clone() }), - ), - (None, None) => None, - }; - let snapshot = source.map(|source| DaytonaSnapshotSettings { - cpu: settings.resources.cpu, - memory: settings - .resources - .memory - .map(|size| size_to_gb_i32(size.as_bytes())), - disk: settings - .resources - .disk - .map(|size| size_to_gb_i32(size.as_bytes())), - source, - }); - - DaytonaConfig { - auto_stop_interval: settings - .lifecycle - .auto_stop - .map(|duration| duration_to_minutes_i32(duration.as_std())), - labels: (!settings.labels.is_empty()).then(|| settings.labels.clone()), - snapshot, - network: Some(match settings.network.mode { - EnvironmentNetworkMode::Block => DaytonaNetwork::Block, - EnvironmentNetworkMode::AllowAll => DaytonaNetwork::AllowAll, - EnvironmentNetworkMode::CidrAllowList => { - DaytonaNetwork::AllowList(settings.network.allow.clone()) - } - }), - clone_depth: clone.depth_limit(), - skip_clone: !clone.enabled, - } -} - -#[must_use] -pub fn docker_config_from_environment( - settings: &RunEnvironmentSettings, - clone: &RunCloneSettings, -) -> DockerSandboxOptions { - // No vault is available on this path (server preflight / manifest), so a - // `{{ secrets.* }}` value keeps its source form. Nothing else is left to - // resolve: `{{ vars.* }}` is substituted at run creation. - #[expect( - clippy::disallowed_methods, - reason = "preflight has no vault, so an unresolved secret token is carried in source \ - form; the real value is resolved by docker_config_from_environment_with_secrets" - )] - let env = settings - .env - .iter() - .map(|(key, value)| (key.clone(), value.as_source())) - .collect(); - docker_config_from_environment_env(settings, clone, env) -} - -pub fn docker_config_from_environment_with_secrets( - settings: &RunEnvironmentSettings, - clone: &RunCloneSettings, - secrets_lookup: impl FnMut(&str) -> Option, -) -> Result { - let env = settings.resolve_env(secrets_lookup)?; - Ok(docker_config_from_environment_env(settings, clone, env)) -} - -fn docker_config_from_environment_env( - settings: &RunEnvironmentSettings, - clone: &RunCloneSettings, - env: std::collections::HashMap, -) -> DockerSandboxOptions { - let mut env_vars = env - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::>(); - env_vars.sort(); - let default_options = DockerSandboxOptions::default(); - - DockerSandboxOptions { - image: settings - .image - .docker - .clone() - .unwrap_or(default_options.image), - network_mode: match settings.network.mode { - EnvironmentNetworkMode::Block => Some("none".to_string()), - EnvironmentNetworkMode::AllowAll | EnvironmentNetworkMode::CidrAllowList => { - default_options.network_mode - } - }, - memory_limit: settings - .resources - .memory - .and_then(|size| i64::try_from(size.as_bytes()).ok()), - cpu_quota: settings - .resources - .cpu - .map(|cpu| i64::from(cpu).saturating_mul(100_000)), - env_vars, - clone_depth: clone - .depth_limit() - .and_then(|depth| usize::try_from(depth).ok()), - skip_clone: !clone.enabled, - ..DockerSandboxOptions::default() - } -} - -pub fn local_working_directory_from_environment( - settings: &RunEnvironmentSettings, - source_directory: Option<&Path>, -) -> crate::Result { - if let Some(cwd) = settings.cwd.as_deref() { - return Ok(PathBuf::from(cwd)); - } - - let Some(source_directory) = source_directory else { - return Err(crate::Error::message( - "local environment requires a server-side working directory; configure `environment.cwd = \"/absolute/path\"` on the selected local environment", - )); - }; - - if source_directory.is_dir() { - return Ok(source_directory.to_path_buf()); - } - - Err(crate::Error::message(format!( - "local environment source_directory does not exist or is not a directory on this server: {}. Configure `environment.cwd = \"/absolute/path\"` on the selected local environment for remote client/server deployments.", - source_directory.display() - ))) -} - -fn duration_to_minutes_i32(duration: std::time::Duration) -> i32 { - let minutes = duration.as_secs() / 60; - i32::try_from(minutes).unwrap_or(i32::MAX) -} - -fn size_to_gb_i32(bytes: u64) -> i32 { - let gb = bytes / 1_000_000_000; - i32::try_from(gb).unwrap_or(i32::MAX) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::path::{Path, PathBuf}; - - use fabro_types::SandboxProviderKind; - use fabro_types::settings::run::{ - EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings, - EnvironmentResourcesSettings, - }; - - use super::*; - - fn run_environment(provider: SandboxProviderKind) -> RunEnvironmentSettings { - RunEnvironmentSettings { - id: "host".to_string(), - provider, - cwd: None, - image: EnvironmentImageSettings::default(), - resources: EnvironmentResourcesSettings::default(), - network: EnvironmentNetworkSettings::default(), - lifecycle: EnvironmentLifecycleSettings::default(), - labels: HashMap::new(), - env: HashMap::new(), - } - } - - #[test] - fn local_working_directory_prefers_environment_cwd() { - let mut settings = run_environment(SandboxProviderKind::LOCAL); - settings.cwd = Some("/srv/fabro/workspaces/team-a".to_string()); - let missing_source = Path::new("/path/that/should/not/exist"); - - let resolved = local_working_directory_from_environment(&settings, Some(missing_source)) - .expect("configured cwd should be accepted"); - - assert_eq!(resolved, PathBuf::from("/srv/fabro/workspaces/team-a")); - assert!(!missing_source.exists()); - } - - #[test] - fn local_working_directory_uses_existing_source_directory_without_cwd() { - let settings = run_environment(SandboxProviderKind::LOCAL); - let dir = tempfile::tempdir().unwrap(); - - let resolved = local_working_directory_from_environment(&settings, Some(dir.path())) - .expect("existing source directory should be accepted"); - - assert_eq!(resolved, dir.path()); - } - - #[test] - fn local_working_directory_rejects_missing_source_directory_without_cwd() { - let settings = run_environment(SandboxProviderKind::LOCAL); - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("client-only"); - - let err = local_working_directory_from_environment(&settings, Some(&missing)) - .expect_err("missing source directory without cwd should fail"); - - let message = err.to_string(); - assert!( - message.contains("environment.cwd") && message.contains("does not exist"), - "unexpected error: {message}" - ); - assert!(!missing.exists()); - } - - #[test] - fn daytona_config_maps_docker_image_to_snapshot() { - let mut settings = run_environment(SandboxProviderKind::DAYTONA); - settings.image.docker = Some("ubuntu:24.04".to_string()); - settings.resources.cpu = Some(2); - - let config = daytona_config_from_environment(&settings, &RunCloneSettings::default()); - let snapshot = config.snapshot.expect("image should configure a snapshot"); - - assert_eq!( - snapshot.source, - DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()) - ); - assert_eq!(snapshot.cpu, Some(2)); - } -} diff --git a/lib/components/fabro-sandbox/src/lib.rs b/lib/components/fabro-sandbox/src/lib.rs index 7c8fade41..d3e8fa796 100644 --- a/lib/components/fabro-sandbox/src/lib.rs +++ b/lib/components/fabro-sandbox/src/lib.rs @@ -1,6 +1,5 @@ -pub mod config; pub mod error; -pub mod from_environment; +pub mod options; pub mod provider; pub mod sandbox; pub mod sandbox_spec; @@ -28,16 +27,15 @@ pub mod terminal; mod clone; pub mod docker; -pub mod plugin; +pub mod provider_sandbox; pub mod daytona; #[cfg(any(test, feature = "test-support"))] 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 docker::check_docker_daemon; pub use driver::{DaytonaCredentials, ProviderAccess}; pub use driver_sandbox::{DriverSandbox, local_sandbox}; pub use error::{Error, Result, default_redacted_output_tail, display_for_log}; @@ -49,11 +47,15 @@ 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 options::{ + SandboxOptions, local_working_directory_from_environment, options_from_environment, + unresolved_env, +}; pub use provider::driver::DriverInventoryProvider; pub use provider::{ LocalSandboxProvider, SandboxLookupError, SandboxProvider, SandboxProviderRegistry, }; +pub use provider_sandbox::{attach_provider_sandbox, provider_sandbox}; pub use push_credentials::RefreshErrorKind; pub use reconnect::{ reconnect, reconnect_driver_for_run, reconnect_for_run, reconnect_for_run_with_callback, @@ -66,5 +68,8 @@ pub use sandbox::{ StderrCollector, StdioProcess, StdioProcessHandle, StdioProcessTermination, WalkOptions, format_lines_numbered, redacted_output_tail, setup_git_via_exec, shell_quote, }; -pub use sandbox_spec::SandboxSpec; +/// The network policy a [`SandboxOptions`] asks for, re-exported so consumers +/// building options need no direct driver dependency. +pub use sandbox_driver::NetworkPolicy; +pub use sandbox_spec::{ProviderSandboxSpec, SandboxSpec}; pub use terminal::{DriverTerminalSession, TerminalSession, TerminalSize, open_terminal_for_run}; diff --git a/lib/components/fabro-sandbox/src/managed_labels.rs b/lib/components/fabro-sandbox/src/managed_labels.rs index ccc72c8cc..f0ecc71fd 100644 --- a/lib/components/fabro-sandbox/src/managed_labels.rs +++ b/lib/components/fabro-sandbox/src/managed_labels.rs @@ -39,12 +39,6 @@ pub(crate) fn verify_managed( Ok(()) } -pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap { - let mut labels = HashMap::new(); - insert_for_run(&mut labels, run_id); - labels -} - pub(crate) fn merge_for_run( user_labels: Option<&HashMap>, run_id: Option<&RunId>, @@ -85,7 +79,7 @@ mod tests { #[test] fn managed_labels_include_run_id_when_present() { let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let labels = for_run(Some(&run_id)); + let labels = merge_for_run(None, Some(&run_id)); assert_eq!(labels.get(MANAGED_LABEL).map(String::as_str), Some("true")); assert_eq!( diff --git a/lib/components/fabro-sandbox/src/options.rs b/lib/components/fabro-sandbox/src/options.rs new file mode 100644 index 000000000..b23848a31 --- /dev/null +++ b/lib/components/fabro-sandbox/src/options.rs @@ -0,0 +1,406 @@ +//! What an environment asks of a sandbox, mapped once for every provider. +//! +//! The environment names an image or Dockerfile, resources, a network +//! policy, labels, variables, a lifecycle, and a clone policy. Every +//! provider consumes the same [`SandboxOptions`]: the driver spec is built +//! from them in one place, and a bundled provider adds only what its +//! backend needs on top (the Docker working directory, the Daytona +//! snapshot and timers) in its own overlay. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use fabro_types::RunId; +use fabro_types::settings::run::{ + DockerfileSource, EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings, +}; +use sandbox_driver::{ + Capabilities, NetworkPolicy, Resources, SandboxSource, SandboxSpec as DriverSpec, +}; + +use crate::managed_labels; + +/// What an environment asks of a sandbox, provider-neutral. +#[derive(Clone, Debug, Default)] +pub struct SandboxOptions { + /// Image reference, when the environment names one. + pub image: Option, + /// Inline Dockerfile, when the environment names one instead of an + /// image. + pub dockerfile: Option, + /// Environment variables for the sandbox, resolved. + pub env: BTreeMap, + pub network: NetworkPolicy, + pub cpu: Option, + pub memory_bytes: Option, + pub disk_bytes: Option, + /// Labels from the environment; fabro's managed labels are added. + pub labels: BTreeMap, + /// Idle time before the provider stops the sandbox, when the + /// environment sets one. + pub auto_stop: Option, + /// Maximum Git history depth fetched during clone; `None` fetches full + /// history. + pub clone_depth: Option, + /// Create an empty workspace instead of cloning even when an origin + /// exists. + pub skip_clone: bool, +} + +impl SandboxOptions { + /// Memory in whole mebibytes, rounded up. + pub fn memory_mb(&self) -> Option { + self.memory_bytes.map(|bytes| bytes.div_ceil(1024 * 1024)) + } + + /// Disk in whole mebibytes, rounded up. + pub fn disk_mb(&self) -> Option { + self.disk_bytes.map(|bytes| bytes.div_ceil(1024 * 1024)) + } +} + +/// Maps resolved environment settings onto sandbox options. `env` is the +/// environment's variables, resolved by the caller: the worker resolves +/// secrets through the vault, while preflight carries them in source form. +/// +/// A Dockerfile given as a path must have been resolved to inline content +/// earlier; none of the providers can read a path. +pub fn options_from_environment( + settings: &RunEnvironmentSettings, + clone: &RunCloneSettings, + env: BTreeMap, +) -> crate::Result { + // fabro-config rejects environments that set both image.docker and + // image.dockerfile. If both still arrive here, the image wins. + let dockerfile = match (&settings.image.docker, &settings.image.dockerfile) { + (Some(_), _) | (None, None) => None, + (None, Some(DockerfileSource::Inline(content))) => Some(content.clone()), + (None, Some(DockerfileSource::Path { path })) => { + return Err(crate::Error::message(format!( + "environment `{}` names a Dockerfile path ({path}) that should have been \ + resolved to inline content before sandbox creation", + settings.id + ))); + } + }; + Ok(SandboxOptions { + image: settings.image.docker.clone(), + dockerfile, + env, + network: match settings.network.mode { + EnvironmentNetworkMode::Block => NetworkPolicy::Block, + EnvironmentNetworkMode::AllowAll => NetworkPolicy::AllowAll, + EnvironmentNetworkMode::CidrAllowList => NetworkPolicy::CidrAllowList { + cidrs: settings.network.allow.clone(), + }, + }, + cpu: settings + .resources + .cpu + .and_then(|cpu| u32::try_from(cpu).ok()), + memory_bytes: settings.resources.memory.map(|size| size.as_bytes()), + disk_bytes: settings.resources.disk.map(|size| size.as_bytes()), + labels: settings + .labels + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + auto_stop: settings + .lifecycle + .auto_stop + .map(|duration| duration.as_std()), + clone_depth: clone + .depth_limit() + .and_then(|depth| u32::try_from(depth).ok()), + skip_clone: !clone.enabled, + }) +} + +/// The environment's variables in source form, for a path with no vault +/// (server preflight): a `{{ secrets.* }}` value keeps its token, and +/// nothing else is left to resolve because `{{ vars.* }}` is substituted at +/// run creation. +pub fn unresolved_env(settings: &RunEnvironmentSettings) -> BTreeMap { + #[expect( + clippy::disallowed_methods, + reason = "preflight has no vault, so an unresolved secret token is carried in source form" + )] + settings + .env + .iter() + .map(|(key, value)| (key.clone(), value.as_source())) + .collect() +} + +pub fn local_working_directory_from_environment( + settings: &RunEnvironmentSettings, + source_directory: Option<&Path>, +) -> crate::Result { + if let Some(cwd) = settings.cwd.as_deref() { + return Ok(PathBuf::from(cwd)); + } + + let Some(source_directory) = source_directory else { + return Err(crate::Error::message( + "local environment requires a server-side working directory; configure `environment.cwd = \"/absolute/path\"` on the selected local environment", + )); + }; + + if source_directory.is_dir() { + return Ok(source_directory.to_path_buf()); + } + + Err(crate::Error::message(format!( + "local environment source_directory does not exist or is not a directory on this server: {}. Configure `environment.cwd = \"/absolute/path\"` on the selected local environment for remote client/server deployments.", + source_directory.display() + ))) +} + +/// The driver spec every provider starts from: the environment's source +/// (an image, a Dockerfile, or a managed directory when it names +/// neither), the run's name and labels, the variables, resources, and +/// network policy. A bundled provider's overlay adjusts what its backend +/// needs. +pub(crate) fn base_spec(options: &SandboxOptions, 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(options.network.clone()); + if let Some(run_id) = run_id { + spec = spec.name(run_name(run_id)); + } + let user_labels: std::collections::HashMap = 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; + resources.memory_mb = options.memory_mb(); + resources.disk_mb = options.disk_mb(); + spec.resources(resources) +} + +/// The provider-side name of a run's sandbox. +pub(crate) fn run_name(run_id: &RunId) -> String { + format!("fabro-run-{run_id}") +} + +/// 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. +pub(crate) fn supported_network( + requested: NetworkPolicy, + capabilities: &Capabilities, +) -> NetworkPolicy { + match requested { + NetworkPolicy::AllowAll if !capabilities.network.allow_all => { + NetworkPolicy::ProviderDefault + } + other => other, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use fabro_types::SandboxProviderKind; + use fabro_types::settings::run::{ + EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings, + EnvironmentResourcesSettings, + }; + use fabro_types::settings::{Duration as SettingsDuration, Size}; + + 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: HashMap::from([("team".to_string(), "platform".to_string())]), + env: HashMap::new(), + } + } + + fn run_id() -> RunId { + "01HY0000000000000000000000".parse().unwrap() + } + + #[test] + fn options_without_an_image_ask_for_a_managed_directory() { + let options = options_from_environment( + &environment("host"), + &RunCloneSettings::default(), + BTreeMap::from([("FOO".to_string(), "bar".to_string())]), + ) + .unwrap(); + assert!(options.image.is_none()); + assert!(options.dockerfile.is_none()); + assert_eq!(options.clone_depth, Some(100)); + assert!(!options.skip_clone); + assert!(options.auto_stop.is_none()); + + let spec = base_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 options_with_an_image_map_resources_network_and_lifecycle() { + let mut settings = environment("e2b"); + settings.image.docker = Some("ubuntu:24.04".to_string()); + settings.resources.cpu = Some(2); + settings.resources.memory = Some(Size::from_bytes(4_000_000_000)); + settings.network.mode = EnvironmentNetworkMode::Block; + settings.lifecycle.auto_stop = Some(SettingsDuration::from_std(Duration::from_mins(45))); + let clone = RunCloneSettings { + enabled: false, + depth: 0, + }; + let options = options_from_environment(&settings, &clone, BTreeMap::new()).unwrap(); + assert_eq!(options.image.as_deref(), Some("ubuntu:24.04")); + assert!(options.skip_clone); + assert_eq!(options.clone_depth, None); + assert_eq!(options.memory_bytes, Some(4_000_000_000)); + assert_eq!(options.memory_mb(), Some(3815)); + assert_eq!(options.auto_stop, Some(Duration::from_mins(45))); + + let spec = base_spec(&options, None); + assert!(matches!( + &spec.source, + SandboxSource::Image { reference } if reference == "ubuntu:24.04" + )); + assert_eq!(spec.resources.cpu_cores, Some(2)); + assert_eq!(spec.resources.memory_mb, Some(3815)); + assert!(matches!(spec.network, NetworkPolicy::Block)); + assert!(spec.name.is_none()); + assert!(!spec.labels.contains_key("sh.fabro.run_id")); + } + + #[test] + fn an_inline_dockerfile_becomes_the_source_and_a_path_is_rejected() { + let mut settings = environment("daytona"); + settings.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu".to_string())); + let options = + options_from_environment(&settings, &RunCloneSettings::default(), BTreeMap::new()) + .unwrap(); + assert_eq!(options.dockerfile.as_deref(), Some("FROM ubuntu")); + assert!(matches!( + base_spec(&options, None).source, + SandboxSource::Dockerfile { content } if content == "FROM ubuntu" + )); + + settings.image.dockerfile = Some(DockerfileSource::Path { + path: "Dockerfile".to_string(), + }); + let error = + options_from_environment(&settings, &RunCloneSettings::default(), BTreeMap::new()) + .unwrap_err(); + assert!(error.to_string().contains("Dockerfile path"), "{error}"); + } + + #[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 local_working_directory_prefers_environment_cwd() { + let mut settings = environment("local"); + settings.cwd = Some("/srv/fabro/workspaces/team-a".to_string()); + let missing_source = Path::new("/path/that/should/not/exist"); + + let resolved = local_working_directory_from_environment(&settings, Some(missing_source)) + .expect("configured cwd should be accepted"); + + assert_eq!(resolved, PathBuf::from("/srv/fabro/workspaces/team-a")); + assert!(!missing_source.exists()); + } + + #[test] + fn local_working_directory_uses_existing_source_directory_without_cwd() { + let settings = environment("local"); + let dir = tempfile::tempdir().unwrap(); + + let resolved = local_working_directory_from_environment(&settings, Some(dir.path())) + .expect("existing source directory should be accepted"); + + assert_eq!(resolved, dir.path()); + } + + #[test] + fn local_working_directory_rejects_missing_source_directory_without_cwd() { + let settings = environment("local"); + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("client-only"); + + let err = local_working_directory_from_environment(&settings, Some(&missing)) + .expect_err("missing source directory without cwd should fail"); + + let message = err.to_string(); + assert!( + message.contains("environment.cwd") && message.contains("does not exist"), + "unexpected error: {message}" + ); + assert!(!missing.exists()); + } +} diff --git a/lib/components/fabro-sandbox/src/plugin.rs b/lib/components/fabro-sandbox/src/plugin.rs deleted file mode 100644 index d8571b216..000000000 --- a/lib/components/fabro-sandbox/src/plugin.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! 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, - /// Inline Dockerfile, when the environment names one instead of an - /// image. - pub dockerfile: Option, - /// Environment variables for the sandbox, resolved. - pub env: BTreeMap, - pub network: PluginNetwork, - pub cpu_cores: Option, - pub memory_mb: Option, - pub disk_mb: Option, - /// Labels from the environment; fabro's managed labels are added. - pub labels: BTreeMap, - /// Maximum Git history depth fetched during clone; `None` fetches full - /// history. - pub clone_depth: Option, - /// 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), -} - -/// 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, -) -> 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 = 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> { - 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, - clone_origin_url: Option, - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, -) -> crate::Result { - 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, - run_id: Option, -) -> crate::Result { - 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()); - } -} diff --git a/lib/components/fabro-sandbox/src/provider_sandbox.rs b/lib/components/fabro-sandbox/src/provider_sandbox.rs new file mode 100644 index 000000000..44a66ca82 --- /dev/null +++ b/lib/components/fabro-sandbox/src/provider_sandbox.rs @@ -0,0 +1,177 @@ +//! Run sandboxes on any provider fabro can name: a bundled kind in process +//! or a sandbox-driver plugin executable. +//! +//! One path builds them all. The environment's [`SandboxOptions`] become +//! the driver spec once, the provider is connected through the single +//! construction function, and a bundled provider adds only what its +//! backend needs on top: Docker its fixed working directory and default +//! image, Daytona the snapshot it creates sandboxes from and its lifecycle +//! timers. A plugin gets the spec as is, laid out inside the working +//! directory the provider chooses. + +use std::sync::Arc; + +use fabro_github::GitHubCredentials; +use fabro_types::{BundledProvider, RunId, SandboxProviderKind}; +use sandbox_driver::{SandboxId, SandboxProvider}; + +use crate::driver::{ProviderAccess, connect_provider}; +use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace}; +use crate::options::{self, SandboxOptions}; +use crate::{daytona, docker, managed_labels}; + +/// A sandbox for a run on `kind`. The sandbox is created by `initialize`; +/// construction validates the clone request and connects the provider, so +/// a bad spec, a missing credential, or a missing plugin executable fails +/// before any backend call. +#[expect( + clippy::too_many_arguments, + reason = "mirrors SandboxSpec::Provider; clone inputs are validated together" +)] +pub async fn provider_sandbox( + kind: SandboxProviderKind, + access: &ProviderAccess, + options: SandboxOptions, + github_app: Option<&GitHubCredentials>, + run_id: Option, + clone_origin_url: Option, + clone_branch: Option, + clone_tag: Option, + clone_commit_sha: Option, +) -> crate::Result { + let workspace = RepoWorkspace::plan( + layout_source(&kind), + 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, access).await?; + let base = options::base_spec(&options, run_id.as_ref()); + Ok(match kind.bundled() { + Some(BundledProvider::Docker) => { + let (spec, image) = docker::overlay(base, &options); + DriverSandbox::pending(kind, provider, spec, Some(image), workspace) + } + Some(BundledProvider::Daytona) => { + let credentials = access + .daytona + .as_ref() + .ok_or_else(|| crate::Error::message(MISSING_DAYTONA_CREDENTIALS))?; + let plan = daytona::create_plan( + Arc::clone(&provider), + credentials.api_key.clone(), + base, + options, + run_id, + ); + DriverSandbox::pending_with_plan(kind, provider, Box::new(plan), workspace) + } + Some(BundledProvider::Local) => { + return Err(crate::Error::message( + "local sandboxes are built from a working directory, not a provider spec", + )); + } + None => { + let mut spec = base; + spec.network = options::supported_network(spec.network, provider.capabilities()); + DriverSandbox::pending(kind, provider, spec, options.image.clone(), workspace) + } + }) +} + +/// Reattach to a run's sandbox on `kind` by its persisted id. +/// +/// The sandbox must carry fabro's managed label and, when a run id is +/// known, the matching run label: the provider shares its backend with +/// every other application, and fabro never operates on a sandbox it did +/// not create. +pub async fn attach_provider_sandbox( + kind: SandboxProviderKind, + access: &ProviderAccess, + sandbox_id: &str, + repo_cloned: bool, + working_directory: String, + clone_origin_url: Option, + run_id: Option, +) -> crate::Result { + let provider = connect(&kind, access).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( + layout_source(&kind), + repo_cloned, + working_directory, + clone_origin_url, + ); + let sandbox = DriverSandbox::attached(kind.clone(), handle, workspace); + if kind.bundled() == Some(BundledProvider::Daytona) { + if let Some(snapshot) = status.source { + sandbox.set_snapshot(snapshot); + } + } + Ok(sandbox) +} + +/// The image the run record names for a sandbox on `kind`: the +/// environment's, or Docker's default when the environment names none. +pub(crate) fn recorded_image( + kind: &SandboxProviderKind, + options: &SandboxOptions, +) -> Option { + match kind.bundled() { + Some(BundledProvider::Docker) => Some(docker::effective_image(options)), + _ => options.image.clone(), + } +} + +/// Where a run's repository checks out on `kind`: fabro fixes the roots +/// inside the containers and VMs it shapes itself, and follows the working +/// directory a plugin provider chooses. +pub(crate) fn layout_source(kind: &SandboxProviderKind) -> LayoutSource { + match kind.bundled() { + Some(BundledProvider::Docker) => LayoutSource::Fixed(docker::layout()), + Some(BundledProvider::Daytona) => LayoutSource::Fixed(daytona::layout()), + Some(BundledProvider::Local) | None => LayoutSource::ProviderWorkingDirectory, + } +} + +/// The in-process Docker provider with default settings, for `fabro doctor`. +pub(crate) async fn connect_bundled_docker( + access: &ProviderAccess, +) -> crate::Result> { + connect(&SandboxProviderKind::DOCKER, access).await +} + +const MISSING_DAYTONA_CREDENTIALS: &str = "Daytona sandboxes require DAYTONA_API_KEY in the vault; run `fabro secret set DAYTONA_API_KEY`"; + +async fn connect( + kind: &SandboxProviderKind, + access: &ProviderAccess, +) -> crate::Result> { + if kind.bundled() == Some(BundledProvider::Daytona) && access.daytona.is_none() { + return Err(crate::Error::message(MISSING_DAYTONA_CREDENTIALS)); + } + let settings = access.settings_for(kind).ok_or_else(|| { + crate::Error::message(format!( + "sandbox provider `{kind}` is not configured; add [server.sandbox.providers.{kind}] to settings.toml" + )) + })?; + connect_provider(kind, &settings, &access.connect_options()) + .await + .map(|connected| connected.provider) + .map_err(|error| { + crate::Error::context(format!("Failed to connect to the {kind} provider"), error) + }) +} diff --git a/lib/components/fabro-sandbox/src/reconnect.rs b/lib/components/fabro-sandbox/src/reconnect.rs index 2eb655ac1..986043280 100644 --- a/lib/components/fabro-sandbox/src/reconnect.rs +++ b/lib/components/fabro-sandbox/src/reconnect.rs @@ -5,7 +5,7 @@ use fabro_types::{BundledProvider, RunId, RunSandboxInstance}; use crate::driver::ProviderAccess; use crate::driver_sandbox::{DriverSandbox, local_sandbox}; -use crate::{SandboxEventCallback, daytona, docker, plugin}; +use crate::{SandboxEventCallback, provider_sandbox}; /// Reconnect to a sandbox from a saved record. /// @@ -46,84 +46,34 @@ pub async fn reconnect_driver_for_run( event_callback: Option, ) -> Result { let runtime = &record.runtime; - match record.provider.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. - Some(BundledProvider::Local) => { - let mut sandbox = local_sandbox(PathBuf::from(&runtime.working_directory)) - .await - .context("Failed to reconnect local sandbox")?; - if let Some(callback) = event_callback { - sandbox.set_event_callback(callback); - } - Ok(sandbox) - } - Some(BundledProvider::Docker) => { - let repo_cloned = runtime - .repo_cloned - .context("Docker run sandbox missing repo_cloned metadata")?; - let mut sandbox = docker::attach_docker( - &runtime.id, - repo_cloned, - runtime.working_directory.clone(), - runtime.clone_origin_url.clone(), - run_id, - ) + // 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. + let mut sandbox = if record.provider.bundled() == Some(BundledProvider::Local) { + local_sandbox(PathBuf::from(&runtime.working_directory)) .await - .context("Failed to reconnect Docker sandbox")?; - if let Some(callback) = event_callback { - sandbox.set_event_callback(callback); - } - Ok(sandbox) - } - Some(BundledProvider::Daytona) => { - let repo_cloned = runtime - .repo_cloned - .context("Daytona run sandbox missing repo_cloned metadata")?; - 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( - &runtime.id, - repo_cloned, - runtime.working_directory.clone(), - runtime.clone_origin_url.clone(), - run_id, - &credentials, + .context("Failed to reconnect local sandbox")? + } else { + let repo_cloned = runtime.repo_cloned.with_context(|| { + format!( + "{} run sandbox missing repo_cloned metadata", + record.provider ) - .await - .context("Failed to reconnect Daytona sandbox")?; - if let Some(callback) = event_callback { - sandbox.set_event_callback(callback); - } - Ok(sandbox) - } - None => { - let settings = access.settings_for(&record.provider).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) - } + })?; + provider_sandbox::attach_provider_sandbox( + record.provider.clone(), + access, + &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) } diff --git a/lib/components/fabro-sandbox/src/sandbox_spec.rs b/lib/components/fabro-sandbox/src/sandbox_spec.rs index 06f576e0a..cb1f23c8a 100644 --- a/lib/components/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/components/fabro-sandbox/src/sandbox_spec.rs @@ -3,64 +3,45 @@ 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}; +use crate::driver::ProviderAccess; +use crate::driver_sandbox::{LayoutSource, local_sandbox}; +use crate::options::SandboxOptions; +use crate::{Sandbox, SandboxEventCallback, clone_source, provider_sandbox}; /// Options for sandbox initialization and construction. +#[derive(Clone, Debug)] pub enum SandboxSpec { Local { working_directory: PathBuf, }, - Docker { - config: DockerSandboxOptions, - github_app: Option, - run_id: Option, - clone_origin_url: Option, - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, - }, - Daytona { - config: Box, - github_app: Option, - run_id: Option, - clone_origin_url: Option, - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, - /// Vault credentials for the Daytona control plane; `None` fails at - /// build time with a clear message rather than reading the process - /// environment. - credentials: Option, - }, - /// A provider served by a sandbox-driver plugin executable. - Plugin { - kind: SandboxProviderKind, - settings: Box, - options: Box, - github_app: Option, - run_id: Option, - clone_origin_url: Option, - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, - }, + /// A sandbox on any provider fabro can name: a bundled kind in process + /// or a sandbox-driver plugin. + Provider(Box), +} + +/// A run's sandbox on a provider: what the environment asked for and how +/// the repository is cloned into it. +#[derive(Clone, Debug)] +pub struct ProviderSandboxSpec { + pub kind: SandboxProviderKind, + /// The provider settings and vault credentials the kind needs. + pub access: ProviderAccess, + pub options: SandboxOptions, + pub github_app: Option, + pub run_id: Option, + pub clone_origin_url: Option, + pub clone_branch: Option, + pub clone_tag: Option, + pub clone_commit_sha: Option, } impl SandboxSpec { pub fn provider(&self) -> SandboxProviderKind { match self { Self::Local { .. } => SandboxProviderKind::LOCAL, - Self::Docker { .. } => SandboxProviderKind::DOCKER, - Self::Daytona { .. } => SandboxProviderKind::DAYTONA, - Self::Plugin { kind, .. } => kind.clone(), + Self::Provider(spec) => spec.kind.clone(), } } @@ -85,98 +66,44 @@ impl SandboxSpec { }; match self { - Self::Docker { - config, - clone_origin_url, - clone_branch, - .. - } => { - let repo_cloned = clone_source::repo_cloned_for_record( - config.skip_clone, - clone_origin_url.as_deref(), - ); - let layout = runtime_layout_metadata( - repo_cloned, - clone_origin_url.as_deref(), - docker::WORKING_DIRECTORY, - docker::REPOS_ROOT, - ); - RunSandboxInstance { - provider: self.provider(), - image: (!config.image.is_empty()).then(|| config.image.clone()), - snapshot: None, - runtime: RunSandboxRuntime { - id, - working_directory: working_directory.clone(), - repo_cloned, - clone_origin_url: clone_source::clean_clone_origin_for_record( - clone_origin_url.as_deref(), - ), - clone_branch: clone_branch.clone(), - workspace_root: Some(docker::WORKING_DIRECTORY.to_string()), - repos_root: Some(docker::REPOS_ROOT.to_string()), - primary_repo_path: layout - .as_ref() - .map(|layout| layout.primary_repo_path.clone()), - primary_repo_link: layout - .as_ref() - .map(|layout| layout.primary_repo_link.clone()), - }, - } - } - Self::Daytona { - config, - clone_origin_url, - clone_branch, - .. - } => { - let repo_cloned = clone_source::repo_cloned_for_record( - config.skip_clone, - clone_origin_url.as_deref(), - ); - let layout = runtime_layout_metadata( - repo_cloned, - clone_origin_url.as_deref(), - daytona::WORKING_DIRECTORY, - daytona::REPOS_ROOT, - ); - RunSandboxInstance { - provider: self.provider(), - image: None, - snapshot: sandbox.snapshot_info(), - runtime: RunSandboxRuntime { - id, - working_directory: working_directory.clone(), - repo_cloned, - clone_origin_url: clone_source::clean_clone_origin_for_record( - clone_origin_url.as_deref(), - ), - clone_branch: clone_branch.clone(), - workspace_root: Some(daytona::WORKING_DIRECTORY.to_string()), - repos_root: Some(daytona::REPOS_ROOT.to_string()), - primary_repo_path: layout - .as_ref() - .map(|layout| layout.primary_repo_path.clone()), - primary_repo_link: layout - .as_ref() - .map(|layout| layout.primary_repo_link.clone()), - }, - } - } - Self::Plugin { - options, - clone_origin_url, - clone_branch, - .. - } => { + Self::Provider(spec) => { + let ProviderSandboxSpec { + kind, + options, + clone_origin_url, + clone_branch, + .. + } = spec.as_ref(); let repo_cloned = clone_source::repo_cloned_for_record( options.skip_clone, clone_origin_url.as_deref(), ); - let layout = sandbox.workspace_layout(); + // A fixed layout is known before the sandbox exists; a + // provider-chosen one only from the sandbox. + let layout = match provider_sandbox::layout_source(kind) { + LayoutSource::Fixed(fixed) => { + let repo = runtime_layout_metadata( + repo_cloned, + clone_origin_url.as_deref(), + &fixed.workspace_root, + &fixed.repos_root, + ); + Some(crate::SandboxWorkspaceLayout { + workspace_root: fixed.workspace_root, + repos_root: fixed.repos_root, + primary_repo_path: repo + .as_ref() + .map(|layout| layout.primary_repo_path.clone()), + primary_repo_link: repo + .as_ref() + .map(|layout| layout.primary_repo_link.clone()), + }) + } + LayoutSource::ProviderWorkingDirectory => sandbox.workspace_layout(), + }; RunSandboxInstance { - provider: self.provider(), - image: options.image.clone(), + provider: kind.clone(), + image: provider_sandbox::recorded_image(kind, options), snapshot: sandbox.snapshot_info(), runtime: RunSandboxRuntime { id, @@ -230,76 +157,22 @@ impl SandboxSpec { } Ok(Arc::new(sandbox)) } - Self::Docker { - config, - github_app, - run_id, - clone_origin_url, - clone_branch, - clone_tag, - clone_commit_sha, - } => { - let mut sandbox = docker::docker_sandbox( - config.clone(), - github_app.as_ref(), - *run_id, - clone_origin_url.clone(), - clone_branch.clone(), - clone_tag.clone(), - clone_commit_sha.clone(), - ) - .await - .context("Failed to create Docker sandbox")?; - if let Some(callback) = event_callback { - sandbox.set_event_callback(callback); - } - Ok(Arc::new(sandbox)) - } - Self::Daytona { - config, - github_app, - run_id, - clone_origin_url, - clone_branch, - clone_tag, - clone_commit_sha, - credentials, - } => { - let credentials = credentials.as_ref().context( - "Daytona sandboxes require DAYTONA_API_KEY in the vault; run `fabro secret set DAYTONA_API_KEY`", - )?; - let mut sandbox = daytona::daytona_sandbox( - config.as_ref().clone(), - github_app.as_ref(), - *run_id, - clone_origin_url.clone(), - clone_branch.clone(), - clone_tag.clone(), - clone_commit_sha.clone(), - credentials, - ) - .await - .context("Failed to create Daytona sandbox")?; - if let Some(callback) = event_callback { - sandbox.set_event_callback(callback); - } - 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( + Self::Provider(spec) => { + let ProviderSandboxSpec { + kind, + access, + options, + github_app, + run_id, + clone_origin_url, + clone_branch, + clone_tag, + clone_commit_sha, + } = spec.as_ref(); + let mut sandbox = provider_sandbox::provider_sandbox( kind.clone(), - settings, - options.as_ref().clone(), + access, + options.clone(), github_app.as_ref(), *run_id, clone_origin_url.clone(), @@ -339,15 +212,17 @@ mod tests { #[test] fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() { - let spec = SandboxSpec::Docker { - config: DockerSandboxOptions::default(), + let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec { + kind: SandboxProviderKind::DOCKER, + access: ProviderAccess::default(), + options: SandboxOptions::default(), github_app: None, run_id: None, clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()), clone_branch: Some("main".to_string()), clone_tag: None, clone_commit_sha: None, - }; + })); let mut sandbox = MockSandbox::linux(); sandbox.working_dir = "/workspace/rack-test"; @@ -377,15 +252,17 @@ mod tests { #[tokio::test] async fn invalid_exact_checkout_spec_fails_before_provider_connection() { - let spec = SandboxSpec::Docker { - config: DockerSandboxOptions::default(), + let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec { + kind: SandboxProviderKind::DOCKER, + access: ProviderAccess::default(), + options: SandboxOptions::default(), github_app: None, run_id: None, clone_origin_url: Some("https://github.com/acme/widgets".to_string()), clone_branch: Some("main".to_string()), clone_tag: None, clone_commit_sha: Some("not-a-sha".to_string()), - }; + })); let error = spec .build(None) @@ -395,7 +272,7 @@ mod tests { assert!( error .to_string() - .contains("Failed to create Docker sandbox") + .contains("Failed to create docker sandbox") ); assert!(format!("{error:#}").contains("40 ASCII hexadecimal")); assert!(!format!("{error:#}").contains("Docker daemon")); @@ -403,10 +280,12 @@ mod tests { #[test] fn docker_run_sandbox_omits_primary_repo_metadata_for_empty_workspace() { - let spec = SandboxSpec::Docker { - config: DockerSandboxOptions { + let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec { + kind: SandboxProviderKind::DOCKER, + access: ProviderAccess::default(), + options: SandboxOptions { skip_clone: true, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, github_app: None, run_id: None, @@ -414,7 +293,7 @@ mod tests { clone_branch: None, clone_tag: None, clone_commit_sha: None, - }; + })); let mut sandbox = MockSandbox::linux(); sandbox.working_dir = "/workspace"; diff --git a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs index 99e980ee4..3e6560650 100644 --- a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs +++ b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs @@ -3,10 +3,9 @@ mod daytona_streaming_live { use std::time::Duration; use anyhow::{Context, Result, ensure}; - use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{ - CommandOutputCallback, DaytonaCredentials, DriverSandbox, ExecStreamingResult, Sandbox, - daytona_sandbox, + CommandOutputCallback, DaytonaCredentials, DriverSandbox, ExecStreamingResult, + ProviderAccess, Sandbox, SandboxOptions, SandboxProviderKind, provider_sandbox, }; use fabro_static::EnvVars; use fabro_types::{CommandOutputStream, CommandTermination}; @@ -29,8 +28,10 @@ mod daytona_streaming_live { ); let sandbox = Arc::new( - daytona_sandbox( - DaytonaConfig { + provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_credentials()?), + SandboxOptions { skip_clone: true, ..Default::default() }, @@ -40,7 +41,6 @@ mod daytona_streaming_live { None, None, None, - &live_credentials()?, ) .await?, ); @@ -68,8 +68,10 @@ mod daytona_streaming_live { "DAYTONA_API_KEY must be set to run this live smoke test" ); - let sandbox = daytona_sandbox( - DaytonaConfig { + let sandbox = provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_credentials()?), + SandboxOptions { skip_clone: true, ..Default::default() }, @@ -79,7 +81,6 @@ mod daytona_streaming_live { None, None, None, - &live_credentials()?, ) .await?; sandbox.initialize().await?; @@ -171,13 +172,15 @@ mod daytona_streaming_live { ); let run_id: fabro_types::RunId = "01HY0000000000000000000000".parse().unwrap(); - let sandbox = daytona_sandbox( - DaytonaConfig { + let sandbox = provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_credentials()?), + SandboxOptions { skip_clone: true, - labels: Some(std::collections::HashMap::from([( + labels: std::collections::BTreeMap::from([( "team".to_string(), "platform".to_string(), - )])), + )]), ..Default::default() }, None, @@ -186,7 +189,6 @@ mod daytona_streaming_live { None, None, None, - &live_credentials()?, ) .await?; @@ -228,8 +230,10 @@ mod daytona_streaming_live { "DAYTONA_API_KEY must be set to run this live smoke test" ); - let sandbox = daytona_sandbox( - DaytonaConfig { + let sandbox = provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_credentials()?), + SandboxOptions { skip_clone: false, ..Default::default() }, @@ -239,7 +243,6 @@ mod daytona_streaming_live { None, None, None, - &live_credentials()?, ) .await?; @@ -297,8 +300,10 @@ mod daytona_streaming_live { "DAYTONA_API_KEY must be set to run this live glob test" ); - let sandbox = daytona_sandbox( - DaytonaConfig { + let sandbox = provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_credentials()?), + SandboxOptions { skip_clone: true, ..Default::default() }, @@ -308,7 +313,6 @@ mod daytona_streaming_live { None, None, None, - &live_credentials()?, ) .await?; @@ -565,6 +569,13 @@ mod daytona_streaming_live { }) } + fn daytona_access(credentials: DaytonaCredentials) -> ProviderAccess { + ProviderAccess { + daytona: Some(credentials), + ..ProviderAccess::default() + } + } + async fn wait_for_chunks( chunks: &Arc>>, timeout_after: Duration, diff --git a/lib/components/fabro-sandbox/tests/docker_streaming.rs b/lib/components/fabro-sandbox/tests/docker_streaming.rs index d8e074a46..7eb12ce64 100644 --- a/lib/components/fabro-sandbox/tests/docker_streaming.rs +++ b/lib/components/fabro-sandbox/tests/docker_streaming.rs @@ -1,9 +1,11 @@ //! Docker sandbox behaviour through the sandbox-driver Docker provider. +use std::collections::BTreeMap; use std::sync::Arc; use fabro_sandbox::{ - CommandOutputCallback, DockerSandboxOptions, ExecStreamingRequest, Sandbox, docker_sandbox, + CommandOutputCallback, ExecStreamingRequest, ProviderAccess, Sandbox, SandboxOptions, + SandboxProviderKind, provider_sandbox, }; use tokio::process::Command; use tokio::sync::Mutex; @@ -38,12 +40,13 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() { return; } - let sandbox = docker_sandbox( - DockerSandboxOptions { - image: image.to_string(), - auto_pull: false, + let sandbox = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some(image.to_string()), skip_clone: true, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, None, None, @@ -110,12 +113,13 @@ async fn streaming_command_receives_exact_stdin_and_eof() { return; } - let sandbox = docker_sandbox( - DockerSandboxOptions { - image: image.to_string(), - auto_pull: false, + let sandbox = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some(image.to_string()), skip_clone: true, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, None, None, @@ -171,12 +175,13 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() { return; } - let sandbox = docker_sandbox( - DockerSandboxOptions { - image: image.to_string(), - auto_pull: false, + let sandbox = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some(image.to_string()), skip_clone: false, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, None, None, @@ -236,13 +241,14 @@ async fn docker_runs_clean_bash_through_both_command_paths() { return; } - let sandbox = docker_sandbox( - DockerSandboxOptions { - image: image.to_string(), - auto_pull: false, - env_vars: vec!["BASH_ENV=/tmp/fabro-bash-env".to_string()], + let sandbox = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some(image.to_string()), + env: BTreeMap::from([("BASH_ENV".to_string(), "/tmp/fabro-bash-env".to_string())]), skip_clone: true, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, None, None, @@ -330,12 +336,13 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() { return; } - let sandbox = docker_sandbox( - DockerSandboxOptions { - image: image.to_string(), - auto_pull: false, + let sandbox = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some(image.to_string()), skip_clone: true, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, None, None, @@ -414,12 +421,13 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() { return; } - let sandbox = docker_sandbox( - DockerSandboxOptions { - image: image.to_string(), - auto_pull: false, + let sandbox = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some(image.to_string()), skip_clone: true, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, None, None, diff --git a/lib/components/fabro-sandbox/tests/driver_bench.rs b/lib/components/fabro-sandbox/tests/driver_bench.rs index 56952b342..f7dca6708 100644 --- a/lib/components/fabro-sandbox/tests/driver_bench.rs +++ b/lib/components/fabro-sandbox/tests/driver_bench.rs @@ -35,7 +35,10 @@ use std::process::Command; use std::sync::Arc; use std::time::{Duration, Instant}; -use fabro_sandbox::{DockerSandboxOptions, Sandbox as FabroSandbox, docker_sandbox, local_sandbox}; +use fabro_sandbox::{ + ProviderAccess, Sandbox as FabroSandbox, SandboxOptions, SandboxProviderKind, local_sandbox, + provider_sandbox, +}; use sandbox_driver::{ ExecSpec, GrepOptions, Sandbox as DriverSandbox, SandboxProvider, SandboxSource, SandboxSpec, Search, @@ -363,12 +366,13 @@ async fn agent_tool_call_latency_through_the_driver() { host.delete().await.expect("host delete"); // -- Docker, in-process: fabro's driver-backed sandbox vs the bare driver. - let fabro_docker = docker_sandbox( - DockerSandboxOptions { - image: IMAGE.to_owned(), - auto_pull: false, + let fabro_docker = provider_sandbox( + SandboxProviderKind::DOCKER, + &ProviderAccess::default(), + SandboxOptions { + image: Some(IMAGE.to_owned()), skip_clone: true, - ..DockerSandboxOptions::default() + ..SandboxOptions::default() }, None, None, diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index 06b59716d..24533cf38 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -9,13 +9,10 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_llm::client::Client as LlmClient; use fabro_mcp::config::McpServerSettings; use fabro_model::{Catalog, ProviderId}; -use fabro_sandbox::daytona::DaytonaConfig; -use fabro_sandbox::from_environment::{ - daytona_config_from_environment, docker_config_from_environment_with_secrets, - local_working_directory_from_environment, +use fabro_sandbox::{ + DaytonaCredentials, ProviderAccess, ProviderSandboxSpec, SandboxOptions, SandboxSpec, + local_working_directory_from_environment, options_from_environment, }; -use fabro_sandbox::plugin::plugin_options_from_environment; -use fabro_sandbox::{DaytonaCredentials, DockerSandboxOptions, SandboxSpec}; use fabro_static::EnvVars; #[cfg(test)] use fabro_types::GitRunTarget; @@ -536,68 +533,27 @@ impl RunSession { SandboxSpec::Local { working_directory } } }, - Some(BundledProvider::Docker) => { - let mut config = resolve_docker_config(resolved, secret_lookup)?; - config.skip_clone |= clone_source.skip_clone; - SandboxSpec::Docker { - config, - 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, - } - } - Some(BundledProvider::Daytona) => { - let credentials = vault_guard.get(EnvVars::DAYTONA_API_KEY).map(|api_key| { + _ => { + let daytona = vault_guard.get(EnvVars::DAYTONA_API_KEY).map(|api_key| { DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var) }); - let mut config = resolve_daytona_config(resolved); - config.skip_clone |= clone_source.skip_clone; - SandboxSpec::Daytona { - config: Box::new(config), + let access = ProviderAccess { + providers: services.sandbox_providers.clone(), + daytona, + }; + let mut options = resolve_sandbox_options(resolved, secret_lookup)?; + options.skip_clone |= clone_source.skip_clone; + SandboxSpec::Provider(Box::new(ProviderSandboxSpec { + kind: sandbox_provider.clone(), + access, + 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, - credentials, - } - } - None => { - 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, - } + })) } }; @@ -856,20 +812,20 @@ fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProviderKi settings.environment.provider.clone() } -fn resolve_daytona_config(settings: &ResolvedRunSettings) -> DaytonaConfig { - daytona_config_from_environment(&settings.environment, &settings.clone) -} - -fn resolve_docker_config( +/// The environment's sandbox options with its variables resolved through +/// the vault. +fn resolve_sandbox_options( settings: &ResolvedRunSettings, secrets_lookup: impl FnMut(&str) -> Option, -) -> Result { - docker_config_from_environment_with_secrets( - &settings.environment, - &settings.clone, - secrets_lookup, - ) - .map_err(|err| Error::engine_with_source("failed to resolve Docker environment config", err)) +) -> Result { + let env = settings + .environment + .resolve_env(secrets_lookup) + .map_err(|err| Error::engine_with_source("failed to resolve environment variables", err))? + .into_iter() + .collect(); + options_from_environment(&settings.environment, &settings.clone, env) + .map_err(|err| Error::engine_with_source("failed to resolve sandbox options", err)) } fn resolve_start_llm( @@ -1620,19 +1576,9 @@ reasoning = false ..RunLayer::default() }); - assert!( - resolve_docker_config(&settings.run, |_| None) - .unwrap() - .skip_clone - ); - assert!(resolve_daytona_config(&settings.run).skip_clone); - assert_eq!(resolve_daytona_config(&settings.run).clone_depth, Some(1)); - assert_eq!( - resolve_docker_config(&settings.run, |_| None) - .unwrap() - .clone_depth, - Some(1) - ); + let options = resolve_sandbox_options(&settings.run, |_| None).unwrap(); + assert!(options.skip_clone); + assert_eq!(options.clone_depth, Some(1)); } #[test] @@ -1645,26 +1591,16 @@ reasoning = false ..RunLayer::default() }); - assert_eq!(resolve_daytona_config(&settings.run).clone_depth, None); - assert_eq!( - resolve_docker_config(&settings.run, |_| None) - .unwrap() - .clone_depth, - None - ); + let options = resolve_sandbox_options(&settings.run, |_| None).unwrap(); + assert_eq!(options.clone_depth, None); } #[test] fn clone_providers_default_to_depth_100() { let settings = settings_from_run_layer(RunLayer::default()); - assert_eq!(resolve_daytona_config(&settings.run).clone_depth, Some(100)); - assert_eq!( - resolve_docker_config(&settings.run, |_| None) - .unwrap() - .clone_depth, - Some(100) - ); + let options = resolve_sandbox_options(&settings.run, |_| None).unwrap(); + assert_eq!(options.clone_depth, Some(100)); } #[test] @@ -1989,17 +1925,19 @@ reasoning = false assert_eq!(runtime.clone_branch, None); assert_eq!(runtime.primary_repo_path, None); assert_eq!(runtime.primary_repo_link, None); - let SandboxSpec::Docker { - config, + let SandboxSpec::Provider(spec) = sandbox else { + panic!("none target should retain the selected Docker provider"); + }; + let ProviderSandboxSpec { + kind, + options, clone_origin_url, clone_branch, clone_commit_sha, .. - } = sandbox - else { - panic!("none target should retain the selected Docker provider"); - }; - assert!(config.skip_clone); + } = *spec; + assert_eq!(kind, SandboxProviderKind::DOCKER); + assert!(options.skip_clone); assert_eq!(clone_origin_url, None); assert_eq!(clone_branch, None); assert_eq!(clone_commit_sha, None); @@ -2056,17 +1994,21 @@ reasoning = false assert_eq!(runtime.clone_branch, None); assert_eq!(runtime.primary_repo_path, None); assert_eq!(runtime.primary_repo_link, None); - let SandboxSpec::Daytona { - config, + let SandboxSpec::Provider(spec) = sandbox else { + panic!("none target should retain the selected Daytona provider"); + }; + let ProviderSandboxSpec { + kind, + access, + options, clone_origin_url, clone_branch, clone_commit_sha, .. - } = sandbox - else { - panic!("none target should retain the selected Daytona provider"); - }; - assert!(config.skip_clone); + } = *spec; + assert_eq!(kind, SandboxProviderKind::DAYTONA); + assert!(access.daytona.is_some(), "the vault key reaches the spec"); + assert!(options.skip_clone); assert_eq!(clone_origin_url, None); assert_eq!(clone_branch, None); assert_eq!(clone_commit_sha, None); @@ -2447,13 +2389,19 @@ reasoning = false ..RunLayer::default() }); - let config = resolve_docker_config(&settings.run, |_| None).unwrap(); + let options = resolve_sandbox_options(&settings.run, |_| None).unwrap(); - assert_eq!(config.image, "ubuntu:24.04"); - assert_eq!(config.cpu_quota, Some(400_000)); - assert_eq!(config.memory_limit, Some(2_000_000_000)); - assert_eq!(config.network_mode.as_deref(), Some("none")); - assert_eq!(config.env_vars, vec!["NODE_ENV=test"]); + assert_eq!(options.image.as_deref(), Some("ubuntu:24.04")); + assert_eq!(options.cpu, Some(4)); + assert_eq!(options.memory_bytes, Some(2_000_000_000)); + assert!(matches!( + options.network, + fabro_sandbox::NetworkPolicy::Block + )); + assert_eq!( + options.env, + std::collections::BTreeMap::from([("NODE_ENV".to_string(), "test".to_string())]) + ); } #[test] diff --git a/lib/components/fabro-workflow/tests/it/daytona_integration.rs b/lib/components/fabro-workflow/tests/it/daytona_integration.rs index b4c677bd5..80d0db9b9 100644 --- a/lib/components/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/components/fabro-workflow/tests/it/daytona_integration.rs @@ -24,8 +24,10 @@ 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, ProviderAccess, daytona_sandbox}; +use fabro_sandbox::{ + DaytonaCredentials, DriverSandbox, ProviderAccess, SandboxOptions, SandboxProviderKind, + provider_sandbox, +}; use fabro_static::EnvVars; use fabro_store::{ArtifactKey, ArtifactStore}; use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref}; @@ -184,6 +186,13 @@ async fn resolve_checkpoint_text( /// Live credentials from the process environment, the way the vault would /// supply them in production. +fn daytona_access(credentials: DaytonaCredentials) -> ProviderAccess { + ProviderAccess { + daytona: Some(credentials), + ..ProviderAccess::default() + } +} + fn live_daytona_credentials() -> DaytonaCredentials { DaytonaCredentials { api_key: std::env::var(EnvVars::DAYTONA_API_KEY) @@ -213,15 +222,16 @@ fn test_artifact_store(run_dir: &Path) -> ArtifactStore { async fn create_env_with_github_app( github_app: Option, ) -> DriverSandbox { - daytona_sandbox( - DaytonaConfig::default(), + provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_daytona_credentials()), + SandboxOptions::default(), github_app.as_ref(), None, None, None, None, None, - &live_daytona_credentials(), ) .await .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?") @@ -411,34 +421,28 @@ async fn daytona_full_lifecycle() { #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_snapshot_sandbox() { - use fabro_sandbox::daytona::DaytonaSnapshotConfig; - - let config = DaytonaConfig { - auto_stop_interval: Some(60), - snapshot: Some(DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: fabro_sandbox::daytona::DaytonaSnapshotSource::Dockerfile( - fabro_sandbox::daytona::DockerfileSource::Inline( - "FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep" - .to_string(), - ), - ), - }), - ..DaytonaConfig::default() + let options = SandboxOptions { + auto_stop: Some(std::time::Duration::from_hours(1)), + dockerfile: Some( + "FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(), + ), + cpu: Some(2), + memory_bytes: Some(4_000_000_000), + disk_bytes: Some(10_000_000_000), + ..SandboxOptions::default() }; let creds = load_github_app_credentials(); - let env = daytona_sandbox( - config, + let env = provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_daytona_credentials()), + options, Some(&creds), None, None, None, None, None, - &live_daytona_credentials(), ) .await .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?"); @@ -1544,7 +1548,7 @@ async fn daytona_toolbox_idle_diagnostic() { #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_cp_upload_download_round_trip() { use fabro_sandbox::reconnect::reconnect; - use fabro_types::{RunSandboxInstance, SandboxProviderKind}; + use fabro_types::RunSandboxInstance; // 1. Create and initialize a real Daytona sandbox let env = create_env().await; @@ -1646,20 +1650,20 @@ async fn daytona_cp_upload_download_round_trip() { #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))] async fn daytona_computer_use_browser_screenshot() { - let config = DaytonaConfig { - snapshot: None, + let options = SandboxOptions { skip_clone: true, - ..DaytonaConfig::default() + ..SandboxOptions::default() }; - let env = daytona_sandbox( - config, + let env = provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_daytona_credentials()), + options, None, None, None, None, None, None, - &live_daytona_credentials(), ) .await .expect("DAYTONA_API_KEY must be set"); @@ -1796,20 +1800,20 @@ async fn daytona_playwright_mcp_sandbox_transport() { use fabro_agent::Sandbox; // Create sandbox from daytona-medium (has Node.js + Chromium) - let config = DaytonaConfig { - snapshot: None, + let options = SandboxOptions { skip_clone: true, - ..DaytonaConfig::default() + ..SandboxOptions::default() }; - let sandbox = daytona_sandbox( - config, + let sandbox = provider_sandbox( + SandboxProviderKind::DAYTONA, + &daytona_access(live_daytona_credentials()), + options, None, None, None, None, None, None, - &live_daytona_credentials(), ) .await .expect("DAYTONA_API_KEY must be set"); diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index 2b6f1da82..30c045900 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -13754,15 +13754,24 @@ async fn asset_collection_local_sandbox_on_failure() { async fn asset_collection_docker_sandbox() { let run_dir = tempfile::tempdir().unwrap(); - let config = fabro_agent::DockerSandboxOptions { - auto_pull: false, + let options = fabro_agent::SandboxOptions { skip_clone: true, ..Default::default() }; let sandbox: Arc = Arc::new( - fabro_agent::docker_sandbox(config, None, None, None, None, None, None) - .await - .expect("Docker not available"), + fabro_agent::provider_sandbox( + fabro_agent::SandboxProviderKind::DOCKER, + &fabro_agent::ProviderAccess::default(), + options, + None, + None, + None, + None, + None, + None, + ) + .await + .expect("Docker not available"), ); sandbox.initialize().await.expect("Docker init failed");