mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
Route Docker sandboxes through the sandbox driver
Fabro's Docker provider kind now maps onto the sandbox-driver Docker provider instead of its own bollard implementation. Fabro keeps what is its own: the clone decision and repository layout, the GitHub App push credentials embedded in origin, git retry classification, the managed labels that gate destructive operations, and the run-facing events. - `clone.rs` performs fabro's clone over the driver `Git` and `Exec` facets. An exact commit goes through the driver's pinned clone; a tag pin runs fabro's init/fetch/attach sequence through `Exec` so the fully qualified tag ref is the only revision consulted. Network failures retry through `git_retry`, which now classifies driver errors (exec output, provider retryability) and never replays an operation whose outcome is unknown. - `DriverSandbox` gains a pending-create state, a `RepoWorkspace` with push-credential state, path resolution against the cloned working directory, git lifecycle methods, image-pull progress mapped from driver events, and an embedded terminal over the driver `Pty` facet. - `docker.rs` builds the driver `SandboxSpec` (image, `/workspace`, fabro labels, env, cpu/memory, network policy, run name) and attaches by persisted container id, refusing containers without fabro's labels. - Inventory, details, diagnostics, terminal, and reconnect run over the driver: a `DriverInventoryProvider` lists by fabro's managed label and projects `SandboxStatus` into fabro's inventory and details shapes. - The bollard-based `docker.rs`, `provider/docker.rs`, Docker terminal, Docker error variants, the `docker` cargo feature, and the bollard and tar dependencies are gone. The Docker integration tests, the agent shell test, the workflow artifact test, and the driver benchmark run against the driver-backed sandbox. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
ba3adb92c7
commit
3d33935fba
37 changed files with 1875 additions and 4213 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2984,7 +2984,6 @@ dependencies = [
|
|||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bollard",
|
||||
"chrono",
|
||||
"daytona-api-client",
|
||||
"daytona-sdk",
|
||||
|
|
@ -3018,7 +3017,6 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"strum 0.28.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -62,8 +62,6 @@ clap_complete = "4"
|
|||
jsonschema = { version = "0.42", default-features = false }
|
||||
chrono = { version = "0.4", features = ["clock", "serde"] }
|
||||
dashmap = "6"
|
||||
bollard = "0.18"
|
||||
tar = "0.4"
|
||||
cli-table = { version = "0.5", default-features = false }
|
||||
console = "0.15"
|
||||
dialoguer = "0.12"
|
||||
|
|
|
|||
|
|
@ -159,8 +159,8 @@ pub trait Sandbox: Send + Sync {
|
|||
|
||||
| Type | Description |
|
||||
|---|---|
|
||||
| `LocalSandbox` | Executes directly on the local filesystem. |
|
||||
| `DockerSandbox` | Runs inside a Docker container (feature-gated: `docker`). |
|
||||
| `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. |
|
||||
|
||||
The `DaytonaSandbox` implementation (feature-gated: `daytona`) runs inside a Daytona cloud sandbox.
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ fabro-slack = { path = "../../components/fabro-slack" }
|
|||
fabro-workflow = { path = "../../components/fabro-workflow" }
|
||||
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
|
||||
fabro-validate = { path = "../../components/fabro-validate" }
|
||||
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona", "docker"] }
|
||||
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona"] }
|
||||
fabro-github = { path = "../../components/fabro-github" }
|
||||
fabro-agent = { path = "../../components/fabro-agent" }
|
||||
fabro-llm = { path = "../../components/fabro-llm" }
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use fabro_llm::client::Client as LlmClient;
|
|||
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe_with_timeout};
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_redact::redact_string;
|
||||
use fabro_sandbox::{DockerSandboxProvider, daytona};
|
||||
use fabro_sandbox::daytona;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_types::settings::ServerAuthMethod;
|
||||
|
|
@ -584,7 +584,7 @@ async fn check_docker_sandbox(state: &AppState) -> CheckResult {
|
|||
.providers
|
||||
.is_enabled(&SandboxProviderKind::DOCKER),
|
||||
|| async {
|
||||
DockerSandboxProvider::check_daemon()
|
||||
fabro_sandbox::check_docker_daemon()
|
||||
.await
|
||||
.map_err(|err| err.display_with_causes())
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,10 +69,11 @@ use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderI
|
|||
use fabro_redact::redact_jsonl_line;
|
||||
use fabro_sandbox::daytona::{self, DaytonaSandbox};
|
||||
use fabro_sandbox::details::sandbox_details;
|
||||
use fabro_sandbox::driver::ProviderConnectOptions;
|
||||
use fabro_sandbox::reconnect::reconnect_for_run;
|
||||
use fabro_sandbox::{
|
||||
DaytonaSandboxProvider, DockerSandboxProvider, LocalSandboxProvider, Sandbox, SandboxProvider,
|
||||
SandboxProviderRegistry,
|
||||
DaytonaSandboxProvider, DriverInventoryProvider, LocalSandboxProvider, Sandbox,
|
||||
SandboxProvider, SandboxProviderRegistry,
|
||||
};
|
||||
use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient};
|
||||
use fabro_slack::config::{
|
||||
|
|
@ -2340,8 +2341,14 @@ fn build_sandbox_provider_registry(
|
|||
providers.push(Arc::new(LocalSandboxProvider));
|
||||
}
|
||||
|
||||
if provider_settings.is_enabled(&SandboxProviderKind::DOCKER) {
|
||||
providers.push(Arc::new(DockerSandboxProvider::new()));
|
||||
if let Some(docker) = provider_settings.get(&SandboxProviderKind::DOCKER) {
|
||||
if docker.enabled {
|
||||
providers.push(Arc::new(DriverInventoryProvider::lazy(
|
||||
SandboxProviderKind::DOCKER,
|
||||
docker.clone(),
|
||||
ProviderConnectOptions::default(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if provider_settings.is_enabled(&SandboxProviderKind::DAYTONA) && daytona_api_key.is_some() {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ keywords = ["llm", "ai", "agent", "coding"]
|
|||
categories = ["api-bindings"]
|
||||
|
||||
[features]
|
||||
default = ["docker"]
|
||||
docker = ["fabro-sandbox/docker"]
|
||||
quarantine = []
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
// Re-export from fabro-sandbox
|
||||
pub use fabro_sandbox::docker::{DockerSandbox, DockerSandboxOptions};
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
#[cfg(feature = "docker")]
|
||||
pub mod docker_sandbox;
|
||||
|
||||
pub mod agent_profile;
|
||||
pub mod apply_patch;
|
||||
pub mod cli;
|
||||
|
|
@ -38,11 +35,10 @@ pub use config::{
|
|||
NativeToolOptions, SessionOptions, ToolAccess, ToolAccessPolicy, ToolApprovalAdapter,
|
||||
ToolExposureMode, ToolHookCallback, ToolHookDecision, ToolSecrets,
|
||||
};
|
||||
#[cfg(feature = "docker")]
|
||||
pub use docker_sandbox::{DockerSandbox, DockerSandboxOptions};
|
||||
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_types::SteeringMessage;
|
||||
pub use history::History;
|
||||
pub use local_sandbox::{DriverSandbox, local_sandbox};
|
||||
|
|
|
|||
|
|
@ -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::{DockerSandbox, DockerSandboxOptions, Emitter};
|
||||
use fabro_agent::{DockerSandboxOptions, Emitter, docker_sandbox};
|
||||
use fabro_types::CommandTermination;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
|
@ -17,7 +17,7 @@ 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) = DockerSandbox::new(
|
||||
let Ok(sandbox) = docker_sandbox(
|
||||
DockerSandboxOptions {
|
||||
image: "buildpack-deps:noble".to_string(),
|
||||
auto_pull: false,
|
||||
|
|
@ -30,7 +30,9 @@ async fn shell_reports_real_docker_process_outcome() {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
) else {
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// No Docker daemon or no local image: the integration precondition is not met.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
mod compaction;
|
||||
#[cfg(feature = "docker")]
|
||||
mod docker_shell;
|
||||
mod guardrails;
|
||||
mod parity_matrix;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ description = "Sandbox trait and implementations for Fabro agent execution envir
|
|||
[features]
|
||||
default = ["local"]
|
||||
local = []
|
||||
docker = ["dep:bollard", "dep:tar"]
|
||||
daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-config", "dep:fabro-http", "dep:reqwest-middleware", "dep:rand", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls"]
|
||||
test-support = []
|
||||
|
||||
|
|
@ -58,10 +57,6 @@ fabro-redact.workspace = true
|
|||
|
||||
futures = { workspace = true }
|
||||
|
||||
# docker
|
||||
bollard = { workspace = true, optional = true }
|
||||
tar = { workspace = true, optional = true }
|
||||
|
||||
# daytona
|
||||
fabro-config = { path = "../../foundation/fabro-config", optional = true }
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
|
|
|
|||
430
lib/components/fabro-sandbox/src/clone.rs
Normal file
430
lib/components/fabro-sandbox/src/clone.rs
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
//! Fabro's clone orchestration over the sandbox-driver [`Git`] and [`Exec`]
|
||||
//! facets.
|
||||
//!
|
||||
//! The driver clones; fabro decides what to clone, where it lands, which
|
||||
//! credentials it carries, and how failures retry. The layout is fabro's:
|
||||
//! the repository checks out under `<repos_root>/<owner>/<repo>` and the
|
||||
//! run works in `<workspace_root>/<repo>`, a symlink to the checkout. An
|
||||
//! exact commit goes through the driver's pinned clone; a tag pin runs
|
||||
//! fabro's own init, fetch, and attach sequence through `Exec`, because a
|
||||
//! tag must be fetched by its fully qualified ref so a same-named branch is
|
||||
//! never consulted. Neither path ever falls back to the branch head.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_github::token_source::ResolvedToken;
|
||||
use fabro_redact::DisplaySafeUrl;
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use sandbox_driver::{Git as _, GitCloneOptions, GitCredentials, Sandbox as DriverHandle};
|
||||
use tokio::time;
|
||||
|
||||
use crate::clone_source::{self, GitHubRepoLayout, PinnedRevision};
|
||||
use crate::exec::SandboxExec;
|
||||
use crate::git_retry::{self, CredentialContext, GitRetryReason, RetryPlan};
|
||||
use crate::push_credentials::PushCredentialState;
|
||||
use crate::redact::redact_auth_url;
|
||||
use crate::sandbox::shell_quote;
|
||||
use crate::{ExecResult, ExecStreamingRequest};
|
||||
|
||||
/// Whole-clone budget, shared by every network and local step.
|
||||
pub(crate) const GIT_CLONE_TIMEOUT: Duration = Duration::from_mins(5);
|
||||
const STEP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// A GitHub clone fabro decided to perform.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GitHubClone {
|
||||
pub(crate) origin_url: String,
|
||||
pub(crate) branch: Option<String>,
|
||||
pub(crate) tag: Option<String>,
|
||||
pub(crate) commit_sha: Option<String>,
|
||||
pub(crate) depth: Option<u32>,
|
||||
}
|
||||
|
||||
/// What the clone left behind: the layout and the token now embedded in
|
||||
/// `origin`, if any.
|
||||
pub(crate) struct CloneOutcome {
|
||||
pub(crate) layout: GitHubRepoLayout,
|
||||
}
|
||||
|
||||
/// Whether a failing git step talked to the remote. Local steps cannot fail
|
||||
/// on credentials, so they must not suggest reconfiguring the GitHub App.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CloneStep {
|
||||
Network,
|
||||
Local,
|
||||
}
|
||||
|
||||
struct CloneFailure {
|
||||
error: crate::Error,
|
||||
retry_reason: Option<GitRetryReason>,
|
||||
}
|
||||
|
||||
/// Clone `plan` into `handle`, laid out under `workspace_root` and
|
||||
/// `repos_root`, embedding a GitHub App token from `credentials` when one
|
||||
/// is available.
|
||||
pub(crate) async fn clone_github_repo(
|
||||
kind: &SandboxProviderKind,
|
||||
handle: &dyn DriverHandle,
|
||||
exec: &SandboxExec<'_>,
|
||||
plan: &GitHubClone,
|
||||
workspace_root: &str,
|
||||
repos_root: &str,
|
||||
credentials: &PushCredentialState,
|
||||
) -> crate::Result<CloneOutcome> {
|
||||
verify_git_available(exec).await?;
|
||||
let layout = clone_source::github_repo_layout(&plan.origin_url, workspace_root, repos_root)?;
|
||||
// The clone mints its own token (never a warm-cache reuse) and seeds the
|
||||
// shared source, so the first refresh compares against the clone token
|
||||
// instead of believing nothing was ever embedded.
|
||||
let resolved_token = match credentials.source() {
|
||||
Some(source) => Some(source.mint_for_clone().await.map_err(|err| {
|
||||
crate::Error::context_anyhow("Failed to get GitHub App credentials for clone", err)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let credential_context =
|
||||
CredentialContext::from_snapshot(resolved_token.as_ref().map(|token| &token.snapshot));
|
||||
let auth_url = match &resolved_token {
|
||||
Some(token) => Some(
|
||||
fabro_github::embed_token_in_url(&plan.origin_url, token.token.expose()).map_err(
|
||||
|err| {
|
||||
crate::Error::context_anyhow(
|
||||
"Failed to build authenticated GitHub clone URL",
|
||||
err,
|
||||
)
|
||||
},
|
||||
)?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let fs = handle.fs();
|
||||
for dir in [workspace_root, layout.repos_owner_path.as_str()] {
|
||||
fs.create_dir(dir)
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(format!("Failed to create {dir}"), error))?;
|
||||
}
|
||||
|
||||
let deadline = time::Instant::now() + GIT_CLONE_TIMEOUT;
|
||||
let has_app = credentials.source().is_some();
|
||||
match PinnedRevision::from_selectors(plan.tag.as_deref(), plan.commit_sha.as_deref()) {
|
||||
Some(PinnedRevision::Tag(tag)) => {
|
||||
// `decide_clone` already requires a branch for a pin; the branch
|
||||
// names the checkout the run works on.
|
||||
let branch = plan
|
||||
.branch
|
||||
.as_deref()
|
||||
.filter(|branch| !branch.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
crate::Error::message("Tag checkout requires a repository branch")
|
||||
})?;
|
||||
clone_pinned_tag(
|
||||
kind,
|
||||
exec,
|
||||
&layout,
|
||||
plan,
|
||||
&tag,
|
||||
branch,
|
||||
auth_url.as_ref(),
|
||||
credential_context,
|
||||
deadline,
|
||||
has_app,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
pin => {
|
||||
let git = handle.git().ok_or_else(|| {
|
||||
crate::Error::message(format!(
|
||||
"sandbox provider `{kind}` does not support git operations"
|
||||
))
|
||||
})?;
|
||||
let mut options = GitCloneOptions::default();
|
||||
options.branch = plan
|
||||
.branch
|
||||
.clone()
|
||||
.filter(|branch| !branch.trim().is_empty());
|
||||
options.commit = plan.commit_sha.clone();
|
||||
options.depth = plan.depth;
|
||||
options.credentials = resolved_token
|
||||
.as_ref()
|
||||
.map(|token| GitCredentials::new("x-access-token", token.token.expose()));
|
||||
let retry_plan = RetryPlan::clone_default(Some(deadline));
|
||||
let target = layout.primary_repo_path.clone();
|
||||
git_retry::retry_git_operation(
|
||||
kind.clone(),
|
||||
"clone",
|
||||
&retry_plan,
|
||||
|_attempt| {
|
||||
let options = options.clone();
|
||||
let target = target.clone();
|
||||
let origin_url = plan.origin_url.clone();
|
||||
let git = &git;
|
||||
async move {
|
||||
git.clone_repo(&origin_url, &target, &options)
|
||||
.await
|
||||
.map_err(|error| CloneFailure {
|
||||
retry_reason: git_retry::classify_driver_failure(
|
||||
&error,
|
||||
credential_context,
|
||||
),
|
||||
error: clone_failure_error(
|
||||
crate::Error::driver_error(error),
|
||||
CloneStep::Network,
|
||||
has_app,
|
||||
),
|
||||
})
|
||||
}
|
||||
},
|
||||
|failure: &CloneFailure| failure.retry_reason,
|
||||
)
|
||||
.await
|
||||
.map_err(|failure| failure.error)?;
|
||||
if let Some(pin) = pin {
|
||||
let head = run_local_step(
|
||||
exec,
|
||||
&clone_source::exact_head_revision_command(&layout.primary_repo_path),
|
||||
"git rev-parse HEAD (pinned checkout)",
|
||||
deadline,
|
||||
auth_url.as_ref(),
|
||||
has_app,
|
||||
)
|
||||
.await?;
|
||||
pin.verify_head(&head.stdout)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run_local_step(
|
||||
exec,
|
||||
&clone_source::repo_symlink_command(&layout),
|
||||
"create workspace repo symlink",
|
||||
deadline,
|
||||
auth_url.as_ref(),
|
||||
has_app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(token) = resolved_token {
|
||||
embed_origin_credentials(exec, &layout, auth_url.as_ref(), token, credentials).await;
|
||||
}
|
||||
Ok(CloneOutcome { layout })
|
||||
}
|
||||
|
||||
/// Fabro's exact tag checkout: init, fetch the fully qualified tag ref at
|
||||
/// the same depth a branch clone gets, attach the admitted branch to the
|
||||
/// fetched commit, and verify HEAD. Every step runs through `Exec`.
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "the pinned path threads clone inputs, credentials, and the shared deadline"
|
||||
)]
|
||||
async fn clone_pinned_tag(
|
||||
kind: &SandboxProviderKind,
|
||||
exec: &SandboxExec<'_>,
|
||||
layout: &GitHubRepoLayout,
|
||||
plan: &GitHubClone,
|
||||
tag: &str,
|
||||
branch: &str,
|
||||
auth_url: Option<&DisplaySafeUrl>,
|
||||
credential_context: CredentialContext,
|
||||
deadline: time::Instant,
|
||||
has_app: bool,
|
||||
) -> crate::Result<()> {
|
||||
let clone_url = auth_url.map_or(plan.origin_url.as_str(), |url| url.as_raw_url().as_str());
|
||||
let init = clone_source::exact_repository_init_command(clone_url, &layout.primary_repo_path);
|
||||
run_local_step(
|
||||
exec,
|
||||
&init,
|
||||
"initialize pinned repository checkout",
|
||||
deadline,
|
||||
auth_url,
|
||||
has_app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let pin = PinnedRevision::Tag(tag.to_string());
|
||||
let fetch = clone_source::pinned_fetch_command(
|
||||
&layout.primary_repo_path,
|
||||
"origin",
|
||||
&pin.fetch_refspec(),
|
||||
plan.depth.map(|depth| depth as usize),
|
||||
);
|
||||
let retry_plan = RetryPlan::clone_default(Some(deadline));
|
||||
git_retry::retry_git_operation(
|
||||
kind.clone(),
|
||||
"fetch",
|
||||
&retry_plan,
|
||||
|_attempt| async {
|
||||
let remaining = deadline.saturating_duration_since(time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(CloneFailure {
|
||||
error: crate::Error::message(
|
||||
"git fetch pinned revision deadline expired before retry",
|
||||
),
|
||||
retry_reason: None,
|
||||
});
|
||||
}
|
||||
let result = exec
|
||||
.run_streaming(ExecStreamingRequest {
|
||||
timeout_ms: Some(millis(remaining)),
|
||||
working_dir: Some("/"),
|
||||
..ExecStreamingRequest::new(&fetch)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CloneFailure {
|
||||
error: crate::Error::context(
|
||||
"git fetch pinned revision transport failed",
|
||||
error,
|
||||
),
|
||||
retry_reason: None,
|
||||
})?
|
||||
.result;
|
||||
if result.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let retry_reason =
|
||||
git_retry::classify_output(&result.stderr, &result.stdout, credential_context)
|
||||
.retry_reason();
|
||||
Err(CloneFailure {
|
||||
error: clone_failure_error(
|
||||
result.into_exec_error_with_redactor("git fetch pinned revision", |output| {
|
||||
redact_auth_url(output, auth_url)
|
||||
}),
|
||||
CloneStep::Network,
|
||||
has_app,
|
||||
),
|
||||
retry_reason,
|
||||
})
|
||||
},
|
||||
|failure: &CloneFailure| failure.retry_reason,
|
||||
)
|
||||
.await
|
||||
.map_err(|failure| failure.error)?;
|
||||
|
||||
let checkout = clone_source::exact_checkout_verify_command(
|
||||
&layout.primary_repo_path,
|
||||
branch,
|
||||
clone_source::FETCH_HEAD_COMMIT,
|
||||
);
|
||||
let head = run_local_step(
|
||||
exec,
|
||||
&checkout,
|
||||
"git checkout pinned revision",
|
||||
deadline,
|
||||
auth_url,
|
||||
has_app,
|
||||
)
|
||||
.await?;
|
||||
pin.verify_head(&head.stdout)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_git_available(exec: &SandboxExec<'_>) -> crate::Result<()> {
|
||||
let result = exec
|
||||
.run("git --version", Some(STEP_TIMEOUT), Some("/"), None, None)
|
||||
.await?;
|
||||
if !result.is_success() {
|
||||
return Err(crate::Error::message(
|
||||
"The sandbox image must include git for repository clone and git lifecycle \
|
||||
operations. Use an image with bash and git, such as buildpack-deps:noble.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a local (non-network) step under the shared clone deadline.
|
||||
///
|
||||
/// Materializing a large working tree takes far longer than the short fixed
|
||||
/// timeout used for trivial commands, so these steps get the same budget the
|
||||
/// network steps have.
|
||||
async fn run_local_step(
|
||||
exec: &SandboxExec<'_>,
|
||||
command: &str,
|
||||
label: &'static str,
|
||||
deadline: time::Instant,
|
||||
auth_url: Option<&DisplaySafeUrl>,
|
||||
has_app: bool,
|
||||
) -> crate::Result<ExecResult> {
|
||||
let remaining = deadline.saturating_duration_since(time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(crate::Error::message(format!(
|
||||
"{label} deadline expired before the step could run"
|
||||
)));
|
||||
}
|
||||
let result = exec
|
||||
.run(command, Some(remaining), Some("/"), None, None)
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(format!("{label} transport failed"), error))?;
|
||||
if result.is_success() {
|
||||
return Ok(result);
|
||||
}
|
||||
Err(clone_failure_error(
|
||||
result.into_exec_error_with_redactor(label, |output| redact_auth_url(output, auth_url)),
|
||||
CloneStep::Local,
|
||||
has_app,
|
||||
))
|
||||
}
|
||||
|
||||
fn clone_failure_error(error: crate::Error, step: CloneStep, has_app: bool) -> crate::Error {
|
||||
let message = match step {
|
||||
CloneStep::Network if !has_app => {
|
||||
"Git clone failed. If this is a private repository, configure a GitHub App with \
|
||||
`fabro install` and install it for your organization."
|
||||
}
|
||||
CloneStep::Network => "Failed to clone repository into the sandbox",
|
||||
CloneStep::Local => "Failed to prepare the cloned repository in the sandbox",
|
||||
};
|
||||
crate::Error::context(message, error)
|
||||
}
|
||||
|
||||
/// Point `origin` at the authenticated URL so pushes from the checkout
|
||||
/// carry the clone token, and record that generation for refreshes. A
|
||||
/// failure here is logged, not fatal: the checkout is complete, and the
|
||||
/// first push will re-embed.
|
||||
async fn embed_origin_credentials(
|
||||
exec: &SandboxExec<'_>,
|
||||
layout: &GitHubRepoLayout,
|
||||
auth_url: Option<&DisplaySafeUrl>,
|
||||
token: ResolvedToken,
|
||||
credentials: &PushCredentialState,
|
||||
) {
|
||||
credentials.record_embedded(token).await;
|
||||
let Some(auth_url) = auth_url else {
|
||||
return;
|
||||
};
|
||||
let command = format!(
|
||||
"git -c maintenance.auto=0 remote set-url origin {}",
|
||||
shell_quote(auth_url.as_raw_url().as_str())
|
||||
);
|
||||
match exec
|
||||
.run(
|
||||
&command,
|
||||
Some(STEP_TIMEOUT),
|
||||
Some(&layout.execution_directory),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.is_success() => {}
|
||||
Ok(result) => {
|
||||
let err = result
|
||||
.into_exec_error_with_redactor("git remote set-url origin (post-clone)", |s| {
|
||||
redact_auth_url(s, Some(auth_url))
|
||||
});
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to set sandbox push credentials on origin; git push from this sandbox will fail"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %redact_auth_url(&crate::display_for_log(&err), Some(auth_url)),
|
||||
"Failed to set sandbox push credentials on origin; git push from this sandbox will fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn millis(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
|
@ -73,7 +73,6 @@ pub(crate) fn repo_symlink_command(layout: &GitHubRepoLayout) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn exact_repository_init_command(clone_url: &str, checkout_path: &str) -> String {
|
||||
format!(
|
||||
"{git} init -- {path} && git -C {path} remote add origin {origin}",
|
||||
|
|
@ -159,7 +158,6 @@ pub(crate) fn tag_ref(tag: &str) -> String {
|
|||
///
|
||||
/// The fetch names the revision directly rather than the branch, and
|
||||
/// `--no-tags` keeps unrelated tags from being pulled alongside it.
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn pinned_fetch_command(
|
||||
checkout_path: &str,
|
||||
fetch_source: &str,
|
||||
|
|
@ -178,7 +176,6 @@ pub(crate) fn pinned_fetch_command(
|
|||
|
||||
/// Leading-space ` --depth N` fragment for a Git command, or empty when
|
||||
/// `depth` is `None` to fetch full history.
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn depth_argument(depth: Option<usize>) -> String {
|
||||
depth.map_or_else(String::new, |depth| format!(" --depth {depth}"))
|
||||
}
|
||||
|
|
@ -215,7 +212,6 @@ pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String {
|
|||
/// Check out the admitted branch and print the resulting HEAD in one shell
|
||||
/// command; stdout is the `rev-parse HEAD` output for
|
||||
/// [`PinnedRevision::verify_head`].
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn exact_checkout_verify_command(
|
||||
checkout_path: &str,
|
||||
branch: &str,
|
||||
|
|
@ -230,7 +226,6 @@ pub(crate) fn exact_checkout_verify_command(
|
|||
|
||||
/// The peeled commit behind whatever `git fetch` just wrote to `FETCH_HEAD`;
|
||||
/// a commit peels to itself, an annotated tag to the commit it points at.
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) const FETCH_HEAD_COMMIT: &str = "FETCH_HEAD^{commit}";
|
||||
|
||||
/// Validate that a `rev-parse HEAD` output is a single commit ID and return it
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::{
|
||||
BundledProvider, RunId, RunSandboxInstance, SandboxDetails, SandboxNetwork, SandboxResources,
|
||||
SandboxState, SandboxTimestamps,
|
||||
};
|
||||
|
||||
use crate::docker;
|
||||
|
||||
/// Inspect the sandbox identified by `record` and return provider-neutral
|
||||
/// details for control-plane display.
|
||||
///
|
||||
/// Provider feature flags determine which branches resolve real data:
|
||||
/// - `local` always returns a minimal record describing the host.
|
||||
/// - `docker` inspects the managed container through Bollard.
|
||||
/// - `daytona` reconnects to the SDK sandbox.
|
||||
/// - `docker` describes the managed container through the sandbox driver.
|
||||
/// - `daytona` reconnects to the SDK sandbox (feature-gated).
|
||||
#[allow(
|
||||
unused_variables,
|
||||
reason = "Feature-gated providers consume some parameters only when enabled."
|
||||
|
|
@ -27,8 +27,7 @@ pub async fn sandbox_details(
|
|||
) -> Result<SandboxDetails> {
|
||||
match record.provider.bundled() {
|
||||
Some(BundledProvider::Local) => Ok(local_details(record)),
|
||||
#[cfg(feature = "docker")]
|
||||
Some(BundledProvider::Docker) => docker::docker_details(record, run_id).await,
|
||||
Some(BundledProvider::Docker) => docker_details(record, run_id).await,
|
||||
#[cfg(feature = "daytona")]
|
||||
Some(BundledProvider::Daytona) => daytona::daytona_details(record, daytona_api_key).await,
|
||||
_ => Err(anyhow::anyhow!(
|
||||
|
|
@ -52,434 +51,128 @@ fn local_details(record: &RunSandboxInstance) -> SandboxDetails {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
#[cfg(feature = "daytona")]
|
||||
fn parse_rfc3339_utc(value: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
pub(crate) mod docker {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use bollard::Docker;
|
||||
use bollard::container::InspectContainerOptions;
|
||||
use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig};
|
||||
use fabro_types::{
|
||||
RunId, RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork,
|
||||
SandboxNetworkPolicy, SandboxProviderKind, SandboxResources, SandboxState,
|
||||
SandboxTimestamps,
|
||||
};
|
||||
|
||||
use super::parse_rfc3339_utc;
|
||||
use crate::docker::WORKING_DIRECTORY;
|
||||
|
||||
pub(super) async fn docker_details(
|
||||
record: &RunSandboxInstance,
|
||||
_run_id: Option<RunId>,
|
||||
) -> Result<SandboxDetails> {
|
||||
let docker =
|
||||
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
|
||||
let runtime = &record.runtime;
|
||||
let inspect = docker
|
||||
.inspect_container(&runtime.id, None::<InspectContainerOptions>)
|
||||
.await
|
||||
.map_err(|err| anyhow!("Failed to inspect Docker container '{}': {err}", runtime.id))?;
|
||||
Ok(map_docker_inspect(&inspect, record))
|
||||
/// Projection of a sandbox-driver [`sandbox_driver::SandboxStatus`] into
|
||||
/// fabro's inventory shape. The driver reports what a provider exposes
|
||||
/// through its public facets; fields no facet carries (network policy) stay
|
||||
/// unknown rather than being read from provider SDK types.
|
||||
pub(crate) fn info_from_status(
|
||||
kind: &fabro_types::SandboxProviderKind,
|
||||
status: &sandbox_driver::SandboxStatus,
|
||||
) -> fabro_types::SandboxInfo {
|
||||
let fields = fields_from_status(status);
|
||||
fabro_types::SandboxInfo {
|
||||
provider: kind.clone(),
|
||||
id: status.id.to_string(),
|
||||
display_name: status.name.clone().filter(|name| !name.is_empty()),
|
||||
state: fields.state,
|
||||
native_state: fields.native_state,
|
||||
image: status.source.clone(),
|
||||
snapshot: None,
|
||||
region: status.region.clone(),
|
||||
web_url: status.web_url.clone(),
|
||||
working_directory: None,
|
||||
resources: fields.resources,
|
||||
network: SandboxNetwork::unknown(),
|
||||
labels: status.labels.clone(),
|
||||
timestamps: fields.timestamps,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn docker_info_from_inspect(inspect: &ContainerInspectResponse) -> SandboxInfo {
|
||||
let fields = docker_fields_from_inspect(inspect);
|
||||
SandboxInfo {
|
||||
provider: SandboxProviderKind::DOCKER,
|
||||
id: fields.id,
|
||||
display_name: fields.display_name,
|
||||
state: fields.state,
|
||||
native_state: fields.native_state,
|
||||
image: fields.image,
|
||||
snapshot: None,
|
||||
region: None,
|
||||
web_url: None,
|
||||
working_directory: fields.working_directory,
|
||||
resources: fields.resources,
|
||||
network: fields.network,
|
||||
labels: fields.labels,
|
||||
timestamps: fields.timestamps,
|
||||
}
|
||||
pub(crate) fn details_from_status(
|
||||
record: &RunSandboxInstance,
|
||||
status: &sandbox_driver::SandboxStatus,
|
||||
) -> SandboxDetails {
|
||||
let fields = fields_from_status(status);
|
||||
SandboxDetails {
|
||||
sandbox: RunSandboxInstance {
|
||||
image: status.source.clone().or_else(|| record.image.clone()),
|
||||
..record.clone()
|
||||
},
|
||||
state: fields.state,
|
||||
native_state: fields.native_state,
|
||||
region: status.region.clone(),
|
||||
web_url: status.web_url.clone(),
|
||||
resources: fields.resources,
|
||||
network: SandboxNetwork::unknown(),
|
||||
labels: status.labels.clone(),
|
||||
timestamps: fields.timestamps,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_docker_inspect(
|
||||
inspect: &ContainerInspectResponse,
|
||||
record: &RunSandboxInstance,
|
||||
) -> SandboxDetails {
|
||||
let fields = docker_fields_from_inspect(inspect);
|
||||
let image = fields.image.clone().or_else(|| record.image.clone());
|
||||
struct StatusFields {
|
||||
state: SandboxState,
|
||||
native_state: Option<String>,
|
||||
resources: SandboxResources,
|
||||
timestamps: SandboxTimestamps,
|
||||
}
|
||||
|
||||
SandboxDetails {
|
||||
sandbox: RunSandboxInstance {
|
||||
image,
|
||||
..record.clone()
|
||||
},
|
||||
state: fields.state,
|
||||
native_state: fields.native_state,
|
||||
region: None,
|
||||
web_url: None,
|
||||
resources: fields.resources,
|
||||
network: fields.network,
|
||||
labels: fields.labels,
|
||||
timestamps: fields.timestamps,
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerFields {
|
||||
id: String,
|
||||
display_name: Option<String>,
|
||||
state: SandboxState,
|
||||
native_state: Option<String>,
|
||||
image: Option<String>,
|
||||
working_directory: Option<String>,
|
||||
resources: SandboxResources,
|
||||
network: SandboxNetwork,
|
||||
labels: BTreeMap<String, String>,
|
||||
timestamps: SandboxTimestamps,
|
||||
}
|
||||
|
||||
fn docker_fields_from_inspect(inspect: &ContainerInspectResponse) -> DockerFields {
|
||||
let status_enum = inspect
|
||||
.state
|
||||
fn fields_from_status(status: &sandbox_driver::SandboxStatus) -> StatusFields {
|
||||
StatusFields {
|
||||
state: normalize_driver_state(status.state),
|
||||
native_state: Some(status.provider_state.clone()).filter(|value| !value.is_empty()),
|
||||
resources: status
|
||||
.resources
|
||||
.as_ref()
|
||||
.and_then(|state| state.status.as_ref())
|
||||
.copied();
|
||||
let normalized_state = status_enum.map_or(SandboxState::Unknown, normalize_docker_state);
|
||||
let native_state = status_enum
|
||||
.map(|status| status.to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let host_config = inspect.host_config.as_ref();
|
||||
let resources = SandboxResources {
|
||||
cpu_cores: host_config.and_then(docker_cpu_cores),
|
||||
memory_bytes: host_config
|
||||
.and_then(|host| host.memory)
|
||||
.filter(|bytes| *bytes > 0)
|
||||
.and_then(|bytes| u64::try_from(bytes).ok()),
|
||||
disk_bytes: None,
|
||||
};
|
||||
let network = docker_network(host_config);
|
||||
|
||||
let labels: BTreeMap<String, String> = inspect
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.labels.clone())
|
||||
.map(|map| map.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let image = inspect
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.image.clone())
|
||||
.or_else(|| inspect.image.clone())
|
||||
.filter(|value| !value.is_empty());
|
||||
let working_directory = inspect
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.working_dir.clone())
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| Some(WORKING_DIRECTORY.to_string()));
|
||||
|
||||
let id = inspect
|
||||
.id
|
||||
.clone()
|
||||
.or_else(|| inspect.name.as_ref().map(|name| trim_container_name(name)))
|
||||
.unwrap_or_default();
|
||||
let display_name = inspect
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|name| trim_container_name(name))
|
||||
.filter(|name| !name.is_empty());
|
||||
|
||||
let created_at = inspect.created.as_deref().and_then(parse_rfc3339_utc);
|
||||
|
||||
DockerFields {
|
||||
id,
|
||||
display_name,
|
||||
state: normalized_state,
|
||||
native_state,
|
||||
image,
|
||||
working_directory,
|
||||
resources,
|
||||
network,
|
||||
labels,
|
||||
timestamps: SandboxTimestamps {
|
||||
created_at,
|
||||
last_activity_at: None,
|
||||
},
|
||||
}
|
||||
.map(|resources| SandboxResources {
|
||||
cpu_cores: resources.cpu_cores.map(f64::from),
|
||||
memory_bytes: resources.memory_mb.map(|mb| mb * 1024 * 1024),
|
||||
disk_bytes: resources.disk_mb.map(|mb| mb * 1024 * 1024),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
timestamps: SandboxTimestamps {
|
||||
created_at: status.created_at.map(DateTime::<Utc>::from),
|
||||
last_activity_at: status.updated_at.map(DateTime::<Utc>::from),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_container_name(name: &str) -> String {
|
||||
name.strip_prefix('/').unwrap_or(name).to_string()
|
||||
pub(crate) fn normalize_driver_state(state: sandbox_driver::SandboxState) -> SandboxState {
|
||||
use sandbox_driver::SandboxState as Driver;
|
||||
match state {
|
||||
Driver::Creating | Driver::Forking => SandboxState::Provisioning,
|
||||
Driver::Starting | Driver::Resuming => SandboxState::Starting,
|
||||
// A sandbox mid-snapshot keeps serving commands.
|
||||
Driver::Running | Driver::Snapshotting => SandboxState::Running,
|
||||
Driver::Stopping | Driver::Archiving => SandboxState::Stopping,
|
||||
Driver::Stopped => SandboxState::Stopped,
|
||||
Driver::Pausing | Driver::Paused => SandboxState::Paused,
|
||||
Driver::Archived => SandboxState::Archived,
|
||||
Driver::Restoring => SandboxState::Restoring,
|
||||
Driver::Resizing => SandboxState::Resizing,
|
||||
Driver::Deleting => SandboxState::Deleting,
|
||||
Driver::Deleted => SandboxState::Deleted,
|
||||
Driver::Error => SandboxState::Error,
|
||||
_ => SandboxState::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn docker_network(host_config: Option<&HostConfig>) -> SandboxNetwork {
|
||||
match host_config.and_then(|host| host.network_mode.as_deref()) {
|
||||
Some("none") => {
|
||||
let blocked = SandboxNetworkPolicy::blocked();
|
||||
SandboxNetwork {
|
||||
egress: blocked.clone(),
|
||||
ingress: blocked,
|
||||
}
|
||||
}
|
||||
_ => SandboxNetwork::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn docker_cpu_cores(host_config: &HostConfig) -> Option<f64> {
|
||||
let quota = host_config.cpu_quota?;
|
||||
let period = host_config.cpu_period?;
|
||||
if quota <= 0 || period <= 0 {
|
||||
return None;
|
||||
}
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "CPU quota/period are bounded well within f64 mantissa precision."
|
||||
)]
|
||||
let cores = (quota as f64) / (period as f64);
|
||||
Some(cores)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_docker_state(status: ContainerStateStatusEnum) -> SandboxState {
|
||||
match status {
|
||||
ContainerStateStatusEnum::EMPTY => SandboxState::Unknown,
|
||||
ContainerStateStatusEnum::CREATED => SandboxState::Provisioning,
|
||||
ContainerStateStatusEnum::RUNNING => SandboxState::Running,
|
||||
ContainerStateStatusEnum::PAUSED => SandboxState::Paused,
|
||||
ContainerStateStatusEnum::RESTARTING => SandboxState::Starting,
|
||||
ContainerStateStatusEnum::REMOVING => SandboxState::Deleting,
|
||||
ContainerStateStatusEnum::EXITED => SandboxState::Stopped,
|
||||
ContainerStateStatusEnum::DEAD => SandboxState::Error,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bollard::models::HostConfig;
|
||||
use fabro_types::{
|
||||
RunSandboxInstance, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy,
|
||||
SandboxProviderKind,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn record() -> RunSandboxInstance {
|
||||
RunSandboxInstance {
|
||||
provider: SandboxProviderKind::DOCKER,
|
||||
image: None,
|
||||
snapshot: None,
|
||||
runtime: RunSandboxRuntime {
|
||||
id: "container-abc123".to_string(),
|
||||
working_directory: "/workspace".to_string(),
|
||||
repo_cloned: Some(true),
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
workspace_root: None,
|
||||
repos_root: None,
|
||||
primary_repo_path: None,
|
||||
primary_repo_link: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_cores_divides_quota_by_period() {
|
||||
let host = HostConfig {
|
||||
cpu_quota: Some(200_000),
|
||||
cpu_period: Some(100_000),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(docker_cpu_cores(&host), Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_cores_returns_none_when_quota_missing() {
|
||||
let host = HostConfig {
|
||||
cpu_quota: None,
|
||||
cpu_period: Some(100_000),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(docker_cpu_cores(&host), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_cores_returns_none_when_period_zero() {
|
||||
let host = HostConfig {
|
||||
cpu_quota: Some(200_000),
|
||||
cpu_period: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(docker_cpu_cores(&host), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_bytes_zero_is_unset() {
|
||||
let inspect = ContainerInspectResponse {
|
||||
host_config: Some(HostConfig {
|
||||
memory: Some(0),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let details = map_docker_inspect(&inspect, &record());
|
||||
assert_eq!(details.resources.memory_bytes, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_bytes_present_is_carried_through() {
|
||||
let inspect = ContainerInspectResponse {
|
||||
host_config: Some(HostConfig {
|
||||
memory: Some(2 * 1024 * 1024 * 1024),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let details = map_docker_inspect(&inspect, &record());
|
||||
assert_eq!(details.resources.memory_bytes, Some(2_147_483_648));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_mode_none_blocks_ingress_and_egress() {
|
||||
let inspect = ContainerInspectResponse {
|
||||
host_config: Some(HostConfig {
|
||||
network_mode: Some("none".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let details = map_docker_inspect(&inspect, &record());
|
||||
assert_eq!(details.network.egress, SandboxNetworkPolicy::blocked());
|
||||
assert_eq!(details.network.ingress, SandboxNetworkPolicy::blocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_none_network_mode_is_unknown() {
|
||||
let inspect = ContainerInspectResponse {
|
||||
host_config: Some(HostConfig {
|
||||
network_mode: Some("bridge".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let details = map_docker_inspect(&inspect, &record());
|
||||
assert_eq!(details.network, SandboxNetwork::unknown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_identity_is_carried_through() {
|
||||
let inspect = ContainerInspectResponse {
|
||||
name: Some("/fabro-run-abc".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let details = map_docker_inspect(&inspect, &record());
|
||||
let runtime = details.sandbox.runtime;
|
||||
assert_eq!(runtime.id, "container-abc123");
|
||||
assert_eq!(runtime.working_directory, "/workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_identity_uses_native_id_and_display_name() {
|
||||
let inspect = ContainerInspectResponse {
|
||||
id: Some("container-abc123".to_string()),
|
||||
name: Some("/fabro-run-abc".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let info = docker_info_from_inspect(&inspect);
|
||||
assert_eq!(info.id, "container-abc123");
|
||||
assert_eq!(info.display_name.as_deref(), Some("fabro-run-abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_working_directory_defaults_to_fabro_workspace() {
|
||||
let inspect = ContainerInspectResponse::default();
|
||||
let info = docker_info_from_inspect(&inspect);
|
||||
assert_eq!(info.working_directory.as_deref(), Some("/workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_status_is_unknown() {
|
||||
assert_eq!(
|
||||
normalize_docker_state(ContainerStateStatusEnum::EMPTY),
|
||||
SandboxState::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_status_is_provisioning() {
|
||||
assert_eq!(
|
||||
normalize_docker_state(ContainerStateStatusEnum::CREATED),
|
||||
SandboxState::Provisioning
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_status_is_running() {
|
||||
assert_eq!(
|
||||
normalize_docker_state(ContainerStateStatusEnum::RUNNING),
|
||||
SandboxState::Running
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_status_is_paused() {
|
||||
assert_eq!(
|
||||
normalize_docker_state(ContainerStateStatusEnum::PAUSED),
|
||||
SandboxState::Paused
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarting_status_is_starting() {
|
||||
assert_eq!(
|
||||
normalize_docker_state(ContainerStateStatusEnum::RESTARTING),
|
||||
SandboxState::Starting
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_status_is_deleting() {
|
||||
assert_eq!(
|
||||
normalize_docker_state(ContainerStateStatusEnum::REMOVING),
|
||||
SandboxState::Deleting
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exited_status_is_stopped() {
|
||||
assert_eq!(
|
||||
normalize_docker_state(ContainerStateStatusEnum::EXITED),
|
||||
SandboxState::Stopped
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_status_is_error() {
|
||||
assert_eq!(
|
||||
normalize_docker_state(ContainerStateStatusEnum::DEAD),
|
||||
SandboxState::Error
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_timestamp_accepts_rfc3339() {
|
||||
let parsed = parse_rfc3339_utc("2026-05-09T12:00:00Z");
|
||||
assert!(parsed.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_timestamp_rejects_garbage() {
|
||||
assert!(parse_rfc3339_utc("not a date").is_none());
|
||||
}
|
||||
}
|
||||
async fn docker_details(
|
||||
record: &RunSandboxInstance,
|
||||
run_id: Option<RunId>,
|
||||
) -> Result<SandboxDetails> {
|
||||
let runtime = &record.runtime;
|
||||
let sandbox = docker::attach_docker(
|
||||
&runtime.id,
|
||||
runtime.repo_cloned.unwrap_or(false),
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
run_id,
|
||||
)
|
||||
.await?;
|
||||
let status = sandbox.handle()?.describe().await.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to describe Docker container '{}': {err}",
|
||||
runtime.id
|
||||
)
|
||||
})?;
|
||||
Ok(details_from_status(record, &status))
|
||||
}
|
||||
|
||||
#[cfg(feature = "daytona")]
|
||||
|
|
@ -823,9 +516,86 @@ pub(crate) mod daytona {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use sandbox_driver::SandboxId;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn driver_states_map_onto_fabro_states() {
|
||||
use sandbox_driver::SandboxState as Driver;
|
||||
for (driver, fabro) in [
|
||||
(Driver::Creating, SandboxState::Provisioning),
|
||||
(Driver::Starting, SandboxState::Starting),
|
||||
(Driver::Running, SandboxState::Running),
|
||||
(Driver::Snapshotting, SandboxState::Running),
|
||||
(Driver::Stopping, SandboxState::Stopping),
|
||||
(Driver::Stopped, SandboxState::Stopped),
|
||||
(Driver::Paused, SandboxState::Paused),
|
||||
(Driver::Archived, SandboxState::Archived),
|
||||
(Driver::Deleting, SandboxState::Deleting),
|
||||
(Driver::Deleted, SandboxState::Deleted),
|
||||
(Driver::Error, SandboxState::Error),
|
||||
(Driver::Unknown, SandboxState::Unknown),
|
||||
] {
|
||||
assert_eq!(normalize_driver_state(driver), fabro, "{driver:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_projection_carries_identity_source_and_labels() {
|
||||
let mut status = sandbox_driver::SandboxStatus::new(
|
||||
SandboxId::try_new("container-abc123").unwrap(),
|
||||
sandbox_driver::SandboxState::Running,
|
||||
);
|
||||
status.name = Some("fabro-run-abc".to_string());
|
||||
status.provider_state = "running".to_string();
|
||||
status.source = Some("buildpack-deps:noble".to_string());
|
||||
status
|
||||
.labels
|
||||
.insert("sh.fabro.managed".to_string(), "true".to_string());
|
||||
let mut resources = sandbox_driver::Resources::default();
|
||||
resources.cpu_cores = Some(2);
|
||||
resources.memory_mb = Some(2048);
|
||||
status.resources = Some(resources);
|
||||
|
||||
let info = info_from_status(&SandboxProviderKind::DOCKER, &status);
|
||||
assert_eq!(info.id, "container-abc123");
|
||||
assert_eq!(info.display_name.as_deref(), Some("fabro-run-abc"));
|
||||
assert_eq!(info.state, SandboxState::Running);
|
||||
assert_eq!(info.native_state.as_deref(), Some("running"));
|
||||
assert_eq!(info.image.as_deref(), Some("buildpack-deps:noble"));
|
||||
assert_eq!(info.resources.cpu_cores, Some(2.0));
|
||||
assert_eq!(info.resources.memory_bytes, Some(2_147_483_648));
|
||||
assert_eq!(
|
||||
info.labels.get("sh.fabro.managed").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
|
||||
let record = RunSandboxInstance {
|
||||
provider: SandboxProviderKind::DOCKER,
|
||||
image: None,
|
||||
snapshot: None,
|
||||
runtime: fabro_types::RunSandboxRuntime {
|
||||
id: "container-abc123".to_string(),
|
||||
working_directory: "/workspace".to_string(),
|
||||
repo_cloned: Some(true),
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
workspace_root: None,
|
||||
repos_root: None,
|
||||
primary_repo_path: None,
|
||||
primary_repo_link: None,
|
||||
},
|
||||
};
|
||||
let details = details_from_status(&record, &status);
|
||||
assert_eq!(
|
||||
details.sandbox.image.as_deref(),
|
||||
Some("buildpack-deps:noble")
|
||||
);
|
||||
assert_eq!(details.sandbox.runtime.id, "container-abc123");
|
||||
assert_eq!(details.network, SandboxNetwork::unknown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_details_returns_running_with_no_metadata() {
|
||||
let record = RunSandboxInstance {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,20 +13,28 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_github::GitHubCredentials;
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use sandbox_driver::{
|
||||
FileKind, LifecycleTimers, Sandbox as DriverHandle, SandboxProvider as _, SandboxSource,
|
||||
Action, Event, EventBody, EventContext, EventObserver, FileKind, LifecycleTimers, ProgressCode,
|
||||
PtyOptions, PtySize, Sandbox as DriverHandle, SandboxProvider as DriverProvider, SandboxSource,
|
||||
SandboxSpec as DriverSpec, SandboxState, Search as _, WaitOptions,
|
||||
};
|
||||
use sandbox_driver_host::HostProvider;
|
||||
use tokio::fs;
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::RetryPlan;
|
||||
use crate::clone::{self, GitHubClone};
|
||||
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason};
|
||||
use crate::push_credentials::{self, PushCredentialState};
|
||||
use crate::terminal::{DriverTerminalSession, TerminalSize};
|
||||
use crate::{GitRunInfo, GitSetupIntent, RefreshOutcome, RetryPlan};
|
||||
|
||||
/// A sandbox on the worker host at `working_directory`, the fabro `local`
|
||||
/// kind, served by the driver's in-process Host provider.
|
||||
|
|
@ -59,10 +67,158 @@ use crate::sandbox::{
|
|||
WalkOptions,
|
||||
};
|
||||
|
||||
/// Where a clone-based provider puts its files: the run works under
|
||||
/// `workspace_root`, and repositories check out under `repos_root`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct WorkspaceLayout {
|
||||
pub(crate) workspace_root: String,
|
||||
pub(crate) repos_root: String,
|
||||
}
|
||||
|
||||
/// What `initialize` does to the workspace once the sandbox runs.
|
||||
enum WorkspacePlan {
|
||||
/// Clone this GitHub repository into the layout.
|
||||
Clone(GitHubClone),
|
||||
/// Create the empty workspace root and nothing else.
|
||||
Empty(EmptyWorkspaceReason),
|
||||
/// The workspace was prepared by an earlier process; leave it alone.
|
||||
Attached,
|
||||
}
|
||||
|
||||
/// Fabro's clone-based workspace on an isolated sandbox: the layout, the
|
||||
/// clone it performs, and the GitHub credentials its checkout carries.
|
||||
pub(crate) struct RepoWorkspace {
|
||||
layout: WorkspaceLayout,
|
||||
plan: WorkspacePlan,
|
||||
credentials: PushCredentialState,
|
||||
repo_cloned: OnceLock<bool>,
|
||||
origin_url: OnceLock<String>,
|
||||
/// The directory the run works in once known: the repository link for a
|
||||
/// clone, the workspace root otherwise.
|
||||
execution_directory: OnceLock<String>,
|
||||
/// The real checkout behind the workspace link, for traversals that
|
||||
/// must not start at a symlink.
|
||||
checkout_path: OnceLock<String>,
|
||||
}
|
||||
|
||||
impl RepoWorkspace {
|
||||
/// Decide the clone for a new sandbox. Fails before any provider call
|
||||
/// when the selectors are inconsistent (a pin without a branch, a
|
||||
/// non-GitHub origin without `skip_clone`).
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "the clone selectors are validated together by decide_clone"
|
||||
)]
|
||||
pub(crate) fn plan(
|
||||
layout: WorkspaceLayout,
|
||||
skip_clone: bool,
|
||||
clone_origin_url: Option<&str>,
|
||||
clone_branch: Option<&str>,
|
||||
clone_tag: Option<&str>,
|
||||
clone_commit_sha: Option<&str>,
|
||||
clone_depth: Option<u32>,
|
||||
github_app: Option<&GitHubCredentials>,
|
||||
) -> crate::Result<Self> {
|
||||
let decision = clone_source::decide_clone(
|
||||
skip_clone,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
clone_tag,
|
||||
clone_commit_sha,
|
||||
)?;
|
||||
let credentials = PushCredentialState::new(push_credentials::build_token_source(
|
||||
github_app,
|
||||
clone_origin_url,
|
||||
)?);
|
||||
let plan = match decision {
|
||||
CloneDecision::EmptyWorkspace { reason } => WorkspacePlan::Empty(reason),
|
||||
CloneDecision::GitHub {
|
||||
origin_url,
|
||||
branch,
|
||||
tag,
|
||||
commit_sha,
|
||||
} => WorkspacePlan::Clone(GitHubClone {
|
||||
origin_url,
|
||||
branch,
|
||||
tag,
|
||||
commit_sha,
|
||||
depth: clone_depth,
|
||||
}),
|
||||
};
|
||||
Ok(Self {
|
||||
layout,
|
||||
plan,
|
||||
credentials,
|
||||
repo_cloned: OnceLock::new(),
|
||||
origin_url: OnceLock::new(),
|
||||
execution_directory: OnceLock::new(),
|
||||
checkout_path: OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A workspace prepared by an earlier process, described by the run
|
||||
/// record. Pushes from a reattached sandbox use whatever credentials the
|
||||
/// checkout's `origin` already carries.
|
||||
pub(crate) fn attached(
|
||||
layout: WorkspaceLayout,
|
||||
repo_cloned: bool,
|
||||
working_directory: String,
|
||||
clone_origin_url: Option<String>,
|
||||
) -> Self {
|
||||
let workspace = Self {
|
||||
layout,
|
||||
plan: WorkspacePlan::Attached,
|
||||
credentials: PushCredentialState::new(None),
|
||||
repo_cloned: OnceLock::new(),
|
||||
origin_url: OnceLock::new(),
|
||||
execution_directory: OnceLock::new(),
|
||||
checkout_path: OnceLock::new(),
|
||||
};
|
||||
let _ = workspace.repo_cloned.set(repo_cloned);
|
||||
let _ = workspace.execution_directory.set(working_directory);
|
||||
if repo_cloned {
|
||||
if let Some(origin) = clone_origin_url {
|
||||
if let Ok(repo_layout) = clone_source::github_repo_layout(
|
||||
&origin,
|
||||
&workspace.layout.workspace_root,
|
||||
&workspace.layout.repos_root,
|
||||
) {
|
||||
let _ = workspace.checkout_path.set(repo_layout.primary_repo_path);
|
||||
}
|
||||
let _ = workspace.origin_url.set(origin);
|
||||
}
|
||||
}
|
||||
workspace
|
||||
}
|
||||
|
||||
fn repo_cloned(&self) -> bool {
|
||||
self.repo_cloned.get().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
self.execution_directory
|
||||
.get()
|
||||
.map_or(self.layout.workspace_root.as_str(), String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// A sandbox that does not exist yet: `initialize` creates it from the
|
||||
/// spec on the provider.
|
||||
struct PendingCreate {
|
||||
provider: Arc<dyn DriverProvider>,
|
||||
spec: DriverSpec,
|
||||
/// The image or snapshot named by the spec, for pull progress events.
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
/// A fabro sandbox backed by a sandbox-driver handle.
|
||||
pub struct DriverSandbox {
|
||||
kind: SandboxProviderKind,
|
||||
handle: Arc<dyn DriverHandle>,
|
||||
/// Set at construction for an existing sandbox, at `initialize` for a
|
||||
/// pending one.
|
||||
handle: OnceCell<Arc<dyn DriverHandle>>,
|
||||
pending: Option<PendingCreate>,
|
||||
workspace: Option<RepoWorkspace>,
|
||||
env_policy: ExplicitEnvPolicy,
|
||||
event_callback: Option<SandboxEventCallback>,
|
||||
/// `(platform, os_version)` learned from the sandbox at initialize or
|
||||
|
|
@ -76,6 +232,43 @@ impl DriverSandbox {
|
|||
/// is isolated and takes the caller's environment as composed.
|
||||
#[must_use]
|
||||
pub fn new(kind: SandboxProviderKind, handle: Arc<dyn DriverHandle>) -> Self {
|
||||
let sandbox = Self::empty(kind);
|
||||
let _ = sandbox.handle.set(handle);
|
||||
sandbox
|
||||
}
|
||||
|
||||
/// A sandbox `initialize` will create from `spec` on `provider`, then
|
||||
/// prepare per `workspace`.
|
||||
pub(crate) fn pending(
|
||||
kind: SandboxProviderKind,
|
||||
provider: Arc<dyn DriverProvider>,
|
||||
spec: DriverSpec,
|
||||
source: Option<String>,
|
||||
workspace: RepoWorkspace,
|
||||
) -> Self {
|
||||
let mut sandbox = Self::empty(kind);
|
||||
sandbox.pending = Some(PendingCreate {
|
||||
provider,
|
||||
spec,
|
||||
source,
|
||||
});
|
||||
sandbox.workspace = Some(workspace);
|
||||
sandbox
|
||||
}
|
||||
|
||||
/// An existing sandbox reattached by handle, with the workspace an
|
||||
/// earlier process prepared.
|
||||
pub(crate) fn attached(
|
||||
kind: SandboxProviderKind,
|
||||
handle: Arc<dyn DriverHandle>,
|
||||
workspace: RepoWorkspace,
|
||||
) -> Self {
|
||||
let mut sandbox = Self::new(kind, handle);
|
||||
sandbox.workspace = Some(workspace);
|
||||
sandbox
|
||||
}
|
||||
|
||||
fn empty(kind: SandboxProviderKind) -> Self {
|
||||
let env_policy = if kind.is_local() {
|
||||
ExplicitEnvPolicy::FilterSensitive
|
||||
} else {
|
||||
|
|
@ -83,7 +276,9 @@ impl DriverSandbox {
|
|||
};
|
||||
Self {
|
||||
kind,
|
||||
handle,
|
||||
handle: OnceCell::new(),
|
||||
pending: None,
|
||||
workspace: None,
|
||||
env_policy,
|
||||
event_callback: None,
|
||||
platform: OnceLock::new(),
|
||||
|
|
@ -101,14 +296,37 @@ impl DriverSandbox {
|
|||
}
|
||||
|
||||
/// The driver handle, for callers that need a facet fabro's trait does
|
||||
/// not carry (git, services, access).
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> &Arc<dyn DriverHandle> {
|
||||
&self.handle
|
||||
/// not carry (git, services, access). Absent until a pending sandbox is
|
||||
/// initialized.
|
||||
pub fn handle(&self) -> crate::Result<&Arc<dyn DriverHandle>> {
|
||||
self.handle.get().ok_or_else(|| {
|
||||
crate::Error::message(format!(
|
||||
"{} sandbox is not initialized; call initialize() first",
|
||||
self.kind
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn exec(&self) -> SandboxExec<'_> {
|
||||
SandboxExec::new(self.handle.exec(), self.env_policy)
|
||||
fn exec(&self) -> crate::Result<SandboxExec<'_>> {
|
||||
let mut exec = SandboxExec::new(self.handle()?.exec(), self.env_policy);
|
||||
if let Some(workspace) = &self.workspace {
|
||||
if let Some(dir) = workspace.execution_directory.get() {
|
||||
exec = exec.with_working_dir(dir.clone());
|
||||
}
|
||||
}
|
||||
Ok(exec)
|
||||
}
|
||||
|
||||
/// Resolve a caller path against fabro's working directory. The driver
|
||||
/// resolves relative paths against the sandbox's own working directory,
|
||||
/// which sits above a cloned repository's link.
|
||||
fn resolve(&self, path: &str) -> String {
|
||||
match &self.workspace {
|
||||
Some(workspace) if workspace.execution_directory.get().is_some() => {
|
||||
sandbox::resolve_path(path, workspace.working_directory())
|
||||
}
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> String {
|
||||
|
|
@ -123,7 +341,7 @@ impl DriverSandbox {
|
|||
}
|
||||
|
||||
fn search(&self) -> crate::Result<sandbox_driver::SearchFacet<'_>> {
|
||||
self.handle.search().ok_or_else(|| {
|
||||
self.handle()?.search().ok_or_else(|| {
|
||||
crate::Error::message(format!(
|
||||
"sandbox provider `{}` does not support search",
|
||||
self.kind
|
||||
|
|
@ -131,18 +349,142 @@ impl DriverSandbox {
|
|||
})
|
||||
}
|
||||
|
||||
/// Create the sandbox on the provider when it does not exist yet.
|
||||
async fn ensure_created(&self) -> crate::Result<()> {
|
||||
if self.handle.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(pending) = &self.pending else {
|
||||
return self.handle().map(|_| ());
|
||||
};
|
||||
let observer = Arc::new(CreateProgress::new(
|
||||
pending.source.clone(),
|
||||
self.event_callback.clone(),
|
||||
));
|
||||
let handle = pending
|
||||
.provider
|
||||
.create(&pending.spec, Some(EventContext::new(observer)))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::context(format!("Failed to create {} sandbox", self.kind), error)
|
||||
})?;
|
||||
let _ = self.handle.set(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bring the sandbox to `Running` with a verified Bash, and learn its
|
||||
/// platform. Shared by initialize and start.
|
||||
async fn make_ready(&self) -> crate::Result<()> {
|
||||
sandbox_driver::activate(self.handle.as_ref(), &WaitOptions::default()).await?;
|
||||
sandbox_driver::activate(self.handle()?.as_ref(), &WaitOptions::default()).await?;
|
||||
self.learn_platform().await
|
||||
}
|
||||
|
||||
/// Prepare the workspace after the sandbox runs for the first time:
|
||||
/// an empty root, or fabro's clone.
|
||||
async fn prepare_workspace(&self) -> crate::Result<()> {
|
||||
let Some(workspace) = &self.workspace else {
|
||||
return Ok(());
|
||||
};
|
||||
match &workspace.plan {
|
||||
WorkspacePlan::Attached => Ok(()),
|
||||
WorkspacePlan::Empty(reason) => {
|
||||
if matches!(reason, EmptyWorkspaceReason::MissingOrigin) {
|
||||
tracing::warn!(
|
||||
provider = %self.kind,
|
||||
reason = reason.message(),
|
||||
"Clone source missing for clone-based sandbox"
|
||||
);
|
||||
}
|
||||
self.handle()?
|
||||
.fs()
|
||||
.create_dir(&workspace.layout.workspace_root)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!("Failed to create {}", workspace.layout.workspace_root),
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
let _ = workspace.repo_cloned.set(false);
|
||||
Ok(())
|
||||
}
|
||||
WorkspacePlan::Clone(plan) => {
|
||||
self.emit(SandboxEvent::GitCloneStarted {
|
||||
url: plan.origin_url.clone(),
|
||||
branch: plan.branch.clone(),
|
||||
});
|
||||
let started = Instant::now();
|
||||
let handle = self.handle()?;
|
||||
// The clone names every directory it touches, so it runs
|
||||
// without fabro's working-directory override.
|
||||
let exec = SandboxExec::new(handle.exec(), self.env_policy);
|
||||
let outcome = clone::clone_github_repo(
|
||||
&self.kind,
|
||||
handle.as_ref(),
|
||||
&exec,
|
||||
plan,
|
||||
&workspace.layout.workspace_root,
|
||||
&workspace.layout.repos_root,
|
||||
&workspace.credentials,
|
||||
)
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(outcome) => {
|
||||
let _ = workspace.repo_cloned.set(true);
|
||||
let _ = workspace.origin_url.set(plan.origin_url.clone());
|
||||
let _ = workspace
|
||||
.checkout_path
|
||||
.set(outcome.layout.primary_repo_path.clone());
|
||||
let _ = workspace
|
||||
.execution_directory
|
||||
.set(outcome.layout.execution_directory.clone());
|
||||
self.emit(SandboxEvent::GitCloneCompleted {
|
||||
url: plan.origin_url.clone(),
|
||||
duration_ms: elapsed_ms(started),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: plan.origin_url.clone(),
|
||||
error: error.to_string(),
|
||||
causes: error.causes(),
|
||||
});
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an interactive shell in the sandbox's working directory over the
|
||||
/// driver's Pty facet.
|
||||
pub async fn open_terminal(&self, size: TerminalSize) -> crate::Result<DriverTerminalSession> {
|
||||
let handle = self.handle()?;
|
||||
let pty = handle.pty().ok_or_else(|| {
|
||||
crate::Error::message(format!(
|
||||
"sandbox provider `{}` does not support terminals",
|
||||
self.kind
|
||||
))
|
||||
})?;
|
||||
let mut options = PtyOptions::default();
|
||||
options.size = PtySize {
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
};
|
||||
options.working_dir = Some(self.working_directory().to_string());
|
||||
let session = pty
|
||||
.open(&options)
|
||||
.await
|
||||
.map_err(|error| crate::Error::context("Failed to open sandbox terminal", error))?;
|
||||
Ok(DriverTerminalSession::new(session))
|
||||
}
|
||||
|
||||
/// Ask the sandbox for its platform once; `platform` and `os_version`
|
||||
/// report `unknown` until this has run.
|
||||
async fn learn_platform(&self) -> crate::Result<()> {
|
||||
if self.platform.get().is_none() {
|
||||
let info = self.handle.platform_info().await?;
|
||||
let info = self.handle()?.platform_info().await?;
|
||||
let platform = fabro_platform_name(&info.os).to_string();
|
||||
let os_version = if info.version.is_empty() {
|
||||
platform.clone()
|
||||
|
|
@ -160,13 +502,109 @@ impl DriverSandbox {
|
|||
/// walked as given.
|
||||
fn walk_base(&self, base: &str, relative_start: &str) -> String {
|
||||
if base == self.working_directory() || base.is_empty() || base == "." {
|
||||
// A cloned repository is reached through a workspace link. The
|
||||
// driver refuses a symlinked traversal root, so walk the real
|
||||
// checkout; results are reported under the link.
|
||||
if let Some(checkout) = self
|
||||
.workspace
|
||||
.as_ref()
|
||||
.and_then(|workspace| workspace.checkout_path.get())
|
||||
{
|
||||
return sandbox::join_sandbox_path(checkout, relative_start);
|
||||
}
|
||||
if relative_start.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
relative_start.to_string()
|
||||
}
|
||||
} else {
|
||||
sandbox::join_sandbox_path(base, relative_start)
|
||||
sandbox::join_sandbox_path(&self.resolve(base), relative_start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the driver's create-time progress into fabro's snapshot events:
|
||||
/// an image pull starts `SnapshotPulling` and the create's completion ends
|
||||
/// it. A create without a pull emits nothing.
|
||||
struct CreateProgress {
|
||||
source: Option<String>,
|
||||
callback: Option<SandboxEventCallback>,
|
||||
pull_started: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl CreateProgress {
|
||||
fn new(source: Option<String>, callback: Option<SandboxEventCallback>) -> Self {
|
||||
Self {
|
||||
source,
|
||||
callback,
|
||||
pull_started: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&self, event: SandboxEvent) {
|
||||
event.trace();
|
||||
if let Some(cb) = &self.callback {
|
||||
cb(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
self.source.clone().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventObserver for CreateProgress {
|
||||
async fn observe(&self, event: Event) {
|
||||
match &event.body {
|
||||
EventBody::OperationProgress { progress, .. }
|
||||
if progress.code.as_str() == ProgressCode::IMAGE_PULL =>
|
||||
{
|
||||
let mut started = self
|
||||
.pull_started
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
if started.is_none() {
|
||||
*started = Some(Instant::now());
|
||||
drop(started);
|
||||
self.emit(SandboxEvent::SnapshotPulling { name: self.name() });
|
||||
}
|
||||
}
|
||||
EventBody::OperationCompleted {
|
||||
action: Action::Create,
|
||||
..
|
||||
} => {
|
||||
let started = self
|
||||
.pull_started
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take();
|
||||
if let Some(started) = started {
|
||||
self.emit(SandboxEvent::SnapshotReady {
|
||||
name: self.name(),
|
||||
duration_ms: elapsed_ms(started),
|
||||
});
|
||||
}
|
||||
}
|
||||
EventBody::OperationFailed {
|
||||
action: Action::Create,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
let started = self
|
||||
.pull_started
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take();
|
||||
if started.is_some() {
|
||||
self.emit(SandboxEvent::SnapshotFailed {
|
||||
name: self.name(),
|
||||
error: error.message.clone(),
|
||||
causes: error.causes.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -186,17 +624,17 @@ fn file_context(action: &str, path: &str) -> String {
|
|||
#[async_trait]
|
||||
impl Sandbox for DriverSandbox {
|
||||
async fn read_file_bytes(&self, path: &str) -> crate::Result<Vec<u8>> {
|
||||
self.handle
|
||||
self.handle()?
|
||||
.fs()
|
||||
.read(path)
|
||||
.read(&self.resolve(path))
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(file_context("read", path), error))
|
||||
}
|
||||
|
||||
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
|
||||
self.handle
|
||||
self.handle()?
|
||||
.fs()
|
||||
.write(path, content.as_bytes())
|
||||
.write(&self.resolve(path), content.as_bytes())
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(file_context("write", path), error))
|
||||
}
|
||||
|
|
@ -210,17 +648,17 @@ impl Sandbox for DriverSandbox {
|
|||
file_context("delete", path)
|
||||
)));
|
||||
}
|
||||
self.handle
|
||||
self.handle()?
|
||||
.fs()
|
||||
.delete(path, false)
|
||||
.delete(&self.resolve(path), false)
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(file_context("delete", path), error))
|
||||
}
|
||||
|
||||
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
|
||||
self.handle
|
||||
self.handle()?
|
||||
.fs()
|
||||
.exists(path)
|
||||
.exists(&self.resolve(path))
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(file_context("stat", path), error))
|
||||
}
|
||||
|
|
@ -231,9 +669,9 @@ impl Sandbox for DriverSandbox {
|
|||
depth: Option<usize>,
|
||||
) -> crate::Result<Vec<DirEntry>> {
|
||||
let entries = self
|
||||
.handle
|
||||
.handle()?
|
||||
.fs()
|
||||
.list_dir(path, depth.unwrap_or(1))
|
||||
.list_dir(&self.resolve(path), depth.unwrap_or(1))
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(file_context("list", path), error))?;
|
||||
let mut entries: Vec<DirEntry> = entries
|
||||
|
|
@ -258,7 +696,7 @@ impl Sandbox for DriverSandbox {
|
|||
env_vars: Option<&HashMap<String, String>>,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> crate::Result<ExecResult> {
|
||||
self.exec()
|
||||
self.exec()?
|
||||
.run(
|
||||
command,
|
||||
Some(Duration::from_millis(timeout_ms)),
|
||||
|
|
@ -273,7 +711,7 @@ impl Sandbox for DriverSandbox {
|
|||
&self,
|
||||
request: ExecStreamingRequest<'_>,
|
||||
) -> crate::Result<ExecStreamingResult> {
|
||||
self.exec().run_streaming(request).await
|
||||
self.exec()?.run_streaming(request).await
|
||||
}
|
||||
|
||||
async fn spawn_stdio_process(
|
||||
|
|
@ -283,7 +721,7 @@ impl Sandbox for DriverSandbox {
|
|||
env_vars: Option<&HashMap<String, String>>,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> crate::Result<StdioProcess> {
|
||||
self.exec()
|
||||
self.exec()?
|
||||
.spawn_stdio(command, working_dir, env_vars, cancel_token)
|
||||
.await
|
||||
}
|
||||
|
|
@ -300,7 +738,7 @@ impl Sandbox for DriverSandbox {
|
|||
driver_options.include.clone_from(&options.glob_filter);
|
||||
let matches = self
|
||||
.search()?
|
||||
.grep(pattern, path, &driver_options)
|
||||
.grep(pattern, &self.resolve(path), &driver_options)
|
||||
.await
|
||||
.map_err(|error| crate::Error::context("Failed to search file contents", error))?;
|
||||
Ok(matches
|
||||
|
|
@ -337,7 +775,7 @@ impl Sandbox for DriverSandbox {
|
|||
let size = match file.size {
|
||||
Some(size) => size,
|
||||
None => {
|
||||
self.handle
|
||||
self.handle()?
|
||||
.fs()
|
||||
.metadata(&path)
|
||||
.await
|
||||
|
|
@ -359,9 +797,9 @@ impl Sandbox for DriverSandbox {
|
|||
remote_path: &str,
|
||||
local_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
self.handle
|
||||
self.handle()?
|
||||
.fs()
|
||||
.download(remote_path, local_path)
|
||||
.download(&self.resolve(remote_path), local_path)
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(file_context("download", remote_path), error))
|
||||
}
|
||||
|
|
@ -371,19 +809,26 @@ impl Sandbox for DriverSandbox {
|
|||
local_path: &Path,
|
||||
remote_path: &str,
|
||||
) -> crate::Result<()> {
|
||||
self.handle
|
||||
self.handle()?
|
||||
.fs()
|
||||
.upload(local_path, remote_path)
|
||||
.upload(local_path, &self.resolve(remote_path))
|
||||
.await
|
||||
.map_err(|error| crate::Error::context(file_context("upload", remote_path), error))
|
||||
}
|
||||
|
||||
/// Create the sandbox when it is pending, bring it to `Running`, and
|
||||
/// prepare fabro's workspace (empty root or clone) on first use.
|
||||
async fn initialize(&self) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::Initializing {
|
||||
provider: self.provider_name(),
|
||||
});
|
||||
let started = Instant::now();
|
||||
let result = self.make_ready().await;
|
||||
let result = async {
|
||||
self.ensure_created().await?;
|
||||
self.make_ready().await?;
|
||||
self.prepare_workspace().await
|
||||
}
|
||||
.await;
|
||||
let duration_ms = elapsed_ms(started);
|
||||
match &result {
|
||||
Ok(()) => self.emit(SandboxEvent::Ready {
|
||||
|
|
@ -407,7 +852,7 @@ impl Sandbox for DriverSandbox {
|
|||
/// Idempotent access-time check: a running sandbox is left alone; a
|
||||
/// stopped or paused one is brought back and its Bash verified.
|
||||
async fn activate(&self) -> crate::Result<()> {
|
||||
let status = self.handle.describe().await?;
|
||||
let status = self.handle()?.describe().await?;
|
||||
if status.state == SandboxState::Running {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -439,7 +884,10 @@ impl Sandbox for DriverSandbox {
|
|||
provider: self.provider_name(),
|
||||
});
|
||||
let started = Instant::now();
|
||||
let result = self.handle.stop().await.map_err(crate::Error::from);
|
||||
let result = match self.handle() {
|
||||
Ok(handle) => handle.stop().await.map_err(crate::Error::from),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
match &result {
|
||||
Ok(()) => self.emit(SandboxEvent::StopCompleted {
|
||||
provider: self.provider_name(),
|
||||
|
|
@ -459,7 +907,7 @@ impl Sandbox for DriverSandbox {
|
|||
provider: self.provider_name(),
|
||||
});
|
||||
let started = Instant::now();
|
||||
let result = self.handle.delete().await.map_err(crate::Error::from);
|
||||
let result = self.release().await;
|
||||
match &result {
|
||||
Ok(()) => self.emit(SandboxEvent::DeleteCompleted {
|
||||
provider: self.provider_name(),
|
||||
|
|
@ -482,7 +930,7 @@ impl Sandbox for DriverSandbox {
|
|||
provider: self.provider_name(),
|
||||
});
|
||||
let started = Instant::now();
|
||||
let result = self.handle.delete().await.map_err(crate::Error::from);
|
||||
let result = self.release().await;
|
||||
match &result {
|
||||
Ok(()) => self.emit(SandboxEvent::CleanupCompleted {
|
||||
provider: self.provider_name(),
|
||||
|
|
@ -497,12 +945,21 @@ impl Sandbox for DriverSandbox {
|
|||
result
|
||||
}
|
||||
|
||||
/// The directory the run works in: the cloned repository's link for a
|
||||
/// clone-based workspace, the provider's working directory otherwise.
|
||||
fn working_directory(&self) -> &str {
|
||||
self.handle.working_directory()
|
||||
if let Some(workspace) = &self.workspace {
|
||||
return workspace.working_directory();
|
||||
}
|
||||
self.handle
|
||||
.get()
|
||||
.map_or("", |handle| handle.working_directory())
|
||||
}
|
||||
|
||||
fn runtime_directory(&self) -> Option<&str> {
|
||||
self.handle.runtime_directory()
|
||||
self.handle
|
||||
.get()
|
||||
.and_then(|handle| handle.runtime_directory())
|
||||
}
|
||||
|
||||
fn platform(&self) -> &str {
|
||||
|
|
@ -521,12 +978,15 @@ impl Sandbox for DriverSandbox {
|
|||
/// The provider's id for this sandbox, or empty for `local`: a local
|
||||
/// sandbox is its working directory, which the run record already
|
||||
/// carries, and its Host registry id does not outlive the process.
|
||||
/// Empty for a pending sandbox that has not been created.
|
||||
fn sandbox_info(&self) -> String {
|
||||
if self.kind.is_local() {
|
||||
String::new()
|
||||
} else {
|
||||
self.handle.id().to_string()
|
||||
return String::new();
|
||||
}
|
||||
self.handle
|
||||
.get()
|
||||
.map(|handle| handle.id().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
|
||||
|
|
@ -535,7 +995,7 @@ impl Sandbox for DriverSandbox {
|
|||
.ok()
|
||||
.filter(|minutes| *minutes > 0)
|
||||
.map(Duration::from_mins);
|
||||
match self.handle.set_timers(&timers).await {
|
||||
match self.handle()?.set_timers(&timers).await {
|
||||
// A provider without timers has nothing to stop automatically.
|
||||
Ok(()) | Err(sandbox_driver::Error::Unsupported { .. }) => Ok(()),
|
||||
Err(error) => Err(crate::Error::context(
|
||||
|
|
@ -545,33 +1005,108 @@ impl Sandbox for DriverSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
async fn setup_git(&self, intent: &GitSetupIntent) -> crate::Result<Option<GitRunInfo>> {
|
||||
if !self.repo_cloned() {
|
||||
return Ok(None);
|
||||
}
|
||||
sandbox::setup_git_via_exec(self, intent).await.map(Some)
|
||||
}
|
||||
|
||||
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
|
||||
if !self.repo_cloned() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![format!(
|
||||
"git fetch origin {} && git checkout {}",
|
||||
sandbox::shell_quote(run_branch),
|
||||
sandbox::shell_quote(run_branch)
|
||||
)]
|
||||
}
|
||||
|
||||
async fn git_push_ref(&self, refspec: &str, plan: &RetryPlan) -> Result<PushReport, PushError> {
|
||||
let has_origin = match self
|
||||
.exec_command("git remote get-url origin", 10_000, None, None, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.is_success() => true,
|
||||
Ok(_) => false,
|
||||
Err(err) => {
|
||||
return Err(PushError {
|
||||
report: PushReport::default(),
|
||||
error: crate::Error::context("git remote get-url origin", err),
|
||||
});
|
||||
let Some(workspace) = &self.workspace else {
|
||||
// A designated directory: push only when the checkout has an
|
||||
// origin, with whatever credentials its URL already carries.
|
||||
let has_origin = match self
|
||||
.exec_command("git remote get-url origin", 10_000, None, None, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.is_success() => true,
|
||||
Ok(_) => false,
|
||||
Err(err) => {
|
||||
return Err(PushError {
|
||||
report: PushReport::default(),
|
||||
error: crate::Error::context("git remote get-url origin", err),
|
||||
});
|
||||
}
|
||||
};
|
||||
if !has_origin {
|
||||
return Ok(PushReport::default());
|
||||
}
|
||||
return sandbox::git_push_via_exec(self, None, refspec, plan).await;
|
||||
};
|
||||
if !has_origin {
|
||||
if !workspace.repo_cloned() {
|
||||
return Ok(PushReport::default());
|
||||
}
|
||||
// No managed credentials yet: the checkout pushes with whatever the
|
||||
// remote URL already carries.
|
||||
sandbox::git_push_via_exec(self, None, refspec, plan).await
|
||||
let credentials = workspace
|
||||
.origin_url
|
||||
.get()
|
||||
.map(|origin_url| (&workspace.credentials, origin_url.as_str()));
|
||||
sandbox::git_push_via_exec(self, credentials, refspec, plan).await
|
||||
}
|
||||
|
||||
fn origin_url(&self) -> Option<&str> {
|
||||
let workspace = self.workspace.as_ref()?;
|
||||
if !workspace.repo_cloned() {
|
||||
return None;
|
||||
}
|
||||
workspace.origin_url.get().map(String::as_str)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))]
|
||||
async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
|
||||
let Some(workspace) = &self.workspace else {
|
||||
return Ok(RefreshOutcome::none());
|
||||
};
|
||||
if !workspace.repo_cloned() {
|
||||
return Ok(RefreshOutcome::none());
|
||||
}
|
||||
let Some(origin_url) = workspace.origin_url.get() else {
|
||||
return Ok(RefreshOutcome::none());
|
||||
};
|
||||
workspace
|
||||
.credentials
|
||||
.refresh(origin_url, |auth_url| {
|
||||
push_credentials::set_auth_url_via_exec(self, auth_url)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn push_token_source(&self) -> Option<Arc<InstallationTokenSource>> {
|
||||
self.workspace
|
||||
.as_ref()
|
||||
.and_then(|workspace| workspace.credentials.source().cloned())
|
||||
}
|
||||
|
||||
/// The local command that opens a shell in the sandbox, from the
|
||||
/// provider's access facet. `None` when the provider has no such
|
||||
/// command (the local sandbox is the host).
|
||||
async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
|
||||
let Some(shell) = self.handle()?.shell_command() else {
|
||||
return Ok(None);
|
||||
};
|
||||
shell
|
||||
.shell_command()
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(|error| crate::Error::context("Failed to build sandbox shell command", error))
|
||||
}
|
||||
|
||||
async fn get_preview_url(
|
||||
&self,
|
||||
port: u16,
|
||||
) -> crate::Result<Option<(String, HashMap<String, String>)>> {
|
||||
let Some(previews) = self.handle.preview_urls() else {
|
||||
let Some(previews) = self.handle()?.preview_urls() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let preview = previews
|
||||
|
|
@ -585,14 +1120,30 @@ impl Sandbox for DriverSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
impl DriverSandbox {
|
||||
fn repo_cloned(&self) -> bool {
|
||||
self.workspace
|
||||
.as_ref()
|
||||
.is_some_and(RepoWorkspace::repo_cloned)
|
||||
}
|
||||
|
||||
/// Delete the sandbox on the provider. A pending sandbox that was never
|
||||
/// created has nothing to release.
|
||||
async fn release(&self) -> crate::Result<()> {
|
||||
match self.handle.get() {
|
||||
Some(handle) => handle.delete().await.map_err(crate::Error::from),
|
||||
None if self.pending.is_some() => Ok(()),
|
||||
None => self.handle().map(|_| ()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(started: Instant) -> u64 {
|
||||
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use fabro_types::CommandTermination;
|
||||
use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec};
|
||||
use sandbox_driver_host::HostProvider;
|
||||
|
|
@ -832,9 +1383,9 @@ mod tests {
|
|||
"",
|
||||
"local sandboxes are identified by directory"
|
||||
);
|
||||
let isolated =
|
||||
DriverSandbox::new(SandboxProviderKind::DOCKER, Arc::clone(f.sandbox.handle()));
|
||||
assert_eq!(isolated.sandbox_info(), f.sandbox.handle().id().to_string());
|
||||
let handle = Arc::clone(f.sandbox.handle().unwrap());
|
||||
let isolated = DriverSandbox::new(SandboxProviderKind::DOCKER, Arc::clone(&handle));
|
||||
assert_eq!(isolated.sandbox_info(), handle.id().to_string());
|
||||
|
||||
f.sandbox.stop().await.unwrap();
|
||||
f.sandbox.activate().await.unwrap();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
use std::fmt::Write as _;
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
use bollard::errors::Error as BollardError;
|
||||
use fabro_util::error::{collect_causes, render_with_causes};
|
||||
|
||||
use crate::ExecResult;
|
||||
|
|
@ -26,29 +24,6 @@ pub enum Error {
|
|||
source: anyhow::Error,
|
||||
},
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[error("Failed to connect to Docker daemon")]
|
||||
DockerConnect {
|
||||
#[source]
|
||||
source: BollardError,
|
||||
},
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[error("Failed to inspect Docker image {image}")]
|
||||
DockerImageInspect {
|
||||
image: String,
|
||||
#[source]
|
||||
source: BollardError,
|
||||
},
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[error("Failed to pull Docker image {image}")]
|
||||
DockerImagePull {
|
||||
image: String,
|
||||
#[source]
|
||||
source: BollardError,
|
||||
},
|
||||
|
||||
/// A sandbox-driver failure: provider, transport, or an operation whose
|
||||
/// outcome is unknown. The driver's own variants stay reachable through
|
||||
/// [`Error::driver`] so callers can act on `NotFound`, `Unsupported`,
|
||||
|
|
@ -101,27 +76,6 @@ impl Error {
|
|||
default_redacted_output_tail(self)
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
pub fn docker_connect(source: BollardError) -> Self {
|
||||
Self::DockerConnect { source }
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
pub fn docker_image_inspect(image: impl Into<String>, source: BollardError) -> Self {
|
||||
Self::DockerImageInspect {
|
||||
image: image.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
pub fn docker_image_pull(image: impl Into<String>, source: BollardError) -> Self {
|
||||
Self::DockerImagePull {
|
||||
image: image.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn causes(&self) -> Vec<String> {
|
||||
collect_causes(self)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,9 +89,12 @@ pub fn is_sensitive_env_var(key: &str) -> bool {
|
|||
|
||||
/// Fabro's exec policy bound to one driver [`Exec`] facet.
|
||||
pub struct SandboxExec<'a> {
|
||||
exec: &'a dyn Exec,
|
||||
env_policy: ExplicitEnvPolicy,
|
||||
stop_grace: Duration,
|
||||
exec: &'a dyn Exec,
|
||||
env_policy: ExplicitEnvPolicy,
|
||||
stop_grace: Duration,
|
||||
/// Where a command runs when the caller names no directory. `None`
|
||||
/// leaves the choice to the provider's own working directory.
|
||||
working_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> SandboxExec<'a> {
|
||||
|
|
@ -101,9 +104,19 @@ impl<'a> SandboxExec<'a> {
|
|||
exec,
|
||||
env_policy,
|
||||
stop_grace: DEFAULT_STOP_GRACE,
|
||||
working_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory commands run in when the caller names none. Fabro's
|
||||
/// working directory can sit below the provider's (a cloned repository
|
||||
/// inside the container workspace), so it is passed explicitly.
|
||||
#[must_use]
|
||||
pub fn with_working_dir(mut self, working_dir: impl Into<String>) -> Self {
|
||||
self.working_dir = Some(working_dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Time between `TERM` and `KILL` when a command is stopped.
|
||||
#[must_use]
|
||||
pub fn with_stop_grace(mut self, stop_grace: Duration) -> Self {
|
||||
|
|
@ -161,7 +174,7 @@ impl<'a> SandboxExec<'a> {
|
|||
let started = Instant::now();
|
||||
|
||||
let mut spec = ExecSpec::bash(command).no_timeout();
|
||||
if let Some(dir) = working_dir {
|
||||
if let Some(dir) = working_dir.or(self.working_dir.as_deref()) {
|
||||
spec = spec.working_dir(dir);
|
||||
}
|
||||
for (key, value) in self.explicit_env(env_vars) {
|
||||
|
|
@ -222,7 +235,7 @@ impl<'a> SandboxExec<'a> {
|
|||
cancel_token: Option<CancellationToken>,
|
||||
) -> crate::Result<StdioProcess> {
|
||||
let mut spec = SpawnSpec::bash(format!("exec {command}"));
|
||||
if let Some(dir) = working_dir {
|
||||
if let Some(dir) = working_dir.or(self.working_dir.as_deref()) {
|
||||
spec = spec.working_dir(dir);
|
||||
}
|
||||
for (key, value) in self.explicit_env(env_vars) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
use fabro_types::settings::ResolveError;
|
||||
#[cfg(feature = "daytona")]
|
||||
use fabro_types::settings::run::DockerfileSource as ResolvedDockerfileSource;
|
||||
|
|
@ -20,7 +19,6 @@ use crate::config::{
|
|||
};
|
||||
#[cfg(feature = "daytona")]
|
||||
use crate::daytona::DaytonaConfig;
|
||||
#[cfg(feature = "docker")]
|
||||
use crate::docker::DockerSandboxOptions;
|
||||
|
||||
#[cfg(feature = "daytona")]
|
||||
|
|
@ -74,7 +72,6 @@ pub fn daytona_config_from_environment(
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[must_use]
|
||||
pub fn docker_config_from_environment(
|
||||
settings: &RunEnvironmentSettings,
|
||||
|
|
@ -96,7 +93,6 @@ pub fn docker_config_from_environment(
|
|||
docker_config_from_environment_env(settings, clone, env)
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
pub fn docker_config_from_environment_with_secrets(
|
||||
settings: &RunEnvironmentSettings,
|
||||
clone: &RunCloneSettings,
|
||||
|
|
@ -106,7 +102,6 @@ pub fn docker_config_from_environment_with_secrets(
|
|||
Ok(docker_config_from_environment_env(settings, clone, env))
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
fn docker_config_from_environment_env(
|
||||
settings: &RunEnvironmentSettings,
|
||||
clone: &RunCloneSettings,
|
||||
|
|
|
|||
|
|
@ -194,6 +194,42 @@ pub fn classify_failure(message: &str, cred: CredentialContext) -> Option<GitRet
|
|||
classify_message(message, cred).retry_reason()
|
||||
}
|
||||
|
||||
/// Classify a sandbox-driver git failure.
|
||||
///
|
||||
/// A command the driver ran surfaces as [`sandbox_driver::Error::Exec`] with
|
||||
/// the git output attached, and is classified like fabro's own exec output.
|
||||
/// A provider-side failure carries a message and a retryability hint. An
|
||||
/// operation whose outcome is unknown (a transport break, a timeout, an
|
||||
/// incomplete operation) is never retried: replaying it could overlap a
|
||||
/// clone that is still running.
|
||||
#[must_use]
|
||||
pub(crate) fn classify_driver_failure(
|
||||
error: &sandbox_driver::Error,
|
||||
cred: CredentialContext,
|
||||
) -> Option<GitRetryReason> {
|
||||
match error {
|
||||
sandbox_driver::Error::Exec(failure) => classify_output(
|
||||
&String::from_utf8_lossy(failure.stderr()),
|
||||
&String::from_utf8_lossy(failure.stdout()),
|
||||
cred,
|
||||
)
|
||||
.retry_reason(),
|
||||
sandbox_driver::Error::Provider(provider) => {
|
||||
match classify_message(&provider.message, cred) {
|
||||
GitMessageClass::Retry(reason) => Some(reason),
|
||||
GitMessageClass::Permanent => None,
|
||||
GitMessageClass::Unknown => {
|
||||
provider.retryable.then_some(GitRetryReason::TransientInfra)
|
||||
}
|
||||
}
|
||||
}
|
||||
sandbox_driver::Error::RateLimited { .. } | sandbox_driver::Error::Overloaded { .. } => {
|
||||
Some(GitRetryReason::TransientInfra)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Backoff between attempts: 3s, then 9s.
|
||||
///
|
||||
/// GitHub's guidance for token replication is to wait a few seconds and retry
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
pub mod config;
|
||||
pub mod error;
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
pub mod from_environment;
|
||||
pub mod provider;
|
||||
pub mod sandbox;
|
||||
pub mod sandbox_spec;
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
mod clone_source;
|
||||
|
||||
mod git_retry;
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona", test))]
|
||||
mod managed_labels;
|
||||
|
||||
mod push_credentials;
|
||||
|
|
@ -29,7 +26,7 @@ pub mod reconnect;
|
|||
|
||||
pub mod terminal;
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
mod clone;
|
||||
pub mod docker;
|
||||
|
||||
#[cfg(feature = "daytona")]
|
||||
|
|
@ -39,8 +36,7 @@ pub mod daytona;
|
|||
pub mod test_support;
|
||||
|
||||
pub use details::sandbox_details;
|
||||
#[cfg(feature = "docker")]
|
||||
pub use docker::{DockerSandbox, DockerSandboxOptions};
|
||||
pub use docker::{DockerSandboxOptions, attach_docker, check_docker_daemon, docker_sandbox};
|
||||
pub use driver_sandbox::{DriverSandbox, local_sandbox};
|
||||
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
|
||||
pub use exec::{ExplicitEnvPolicy, SandboxExec, is_sensitive_env_var};
|
||||
|
|
@ -53,11 +49,9 @@ pub use git_retry::{
|
|||
};
|
||||
#[cfg(feature = "daytona")]
|
||||
pub use provider::daytona::DaytonaSandboxProvider;
|
||||
#[cfg(feature = "docker")]
|
||||
pub use provider::docker::DockerSandboxProvider;
|
||||
pub use provider::driver::DriverInventoryProvider;
|
||||
pub use provider::{
|
||||
LocalSandboxProvider, SandboxCreateSpec, SandboxLookupError, SandboxProvider,
|
||||
SandboxProviderRegistry,
|
||||
LocalSandboxProvider, SandboxLookupError, SandboxProvider, SandboxProviderRegistry,
|
||||
};
|
||||
pub use push_credentials::RefreshErrorKind;
|
||||
pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callback};
|
||||
|
|
@ -70,4 +64,4 @@ pub use sandbox::{
|
|||
redacted_output_tail, setup_git_via_exec, shell_quote,
|
||||
};
|
||||
pub use sandbox_spec::SandboxSpec;
|
||||
pub use terminal::{TerminalSession, TerminalSize, open_terminal_for_run};
|
||||
pub use terminal::{DriverTerminalSession, TerminalSession, TerminalSize, open_terminal_for_run};
|
||||
|
|
|
|||
|
|
@ -7,12 +7,10 @@ pub(crate) const MANAGED_LABEL_VALUE: &str = "true";
|
|||
pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id";
|
||||
|
||||
/// True when the provided label map carries the Fabro managed sentinel.
|
||||
#[cfg(any(feature = "docker", feature = "daytona", test))]
|
||||
pub(crate) fn is_managed(labels: &HashMap<String, String>) -> bool {
|
||||
labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "docker", test))]
|
||||
pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap<String, String> {
|
||||
let mut labels = HashMap::new();
|
||||
insert_for_run(&mut labels, run_id);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
#[cfg(feature = "daytona")]
|
||||
pub mod daytona;
|
||||
#[cfg(feature = "docker")]
|
||||
pub mod docker;
|
||||
pub mod driver;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
use fabro_github::GitHubCredentials;
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::{
|
||||
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind,
|
||||
SandboxProviderLookupError,
|
||||
|
|
@ -17,39 +12,12 @@ use fabro_types::{
|
|||
use fabro_util::error::collect_chain;
|
||||
use futures::future::join_all;
|
||||
|
||||
#[cfg(feature = "daytona")]
|
||||
use crate::daytona::DaytonaConfig;
|
||||
#[cfg(feature = "docker")]
|
||||
use crate::docker::DockerSandboxOptions;
|
||||
|
||||
pub enum SandboxCreateSpec {
|
||||
Local,
|
||||
#[cfg(feature = "docker")]
|
||||
Docker {
|
||||
config: DockerSandboxOptions,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
},
|
||||
#[cfg(feature = "daytona")]
|
||||
Daytona {
|
||||
config: Box<DaytonaConfig>,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
api_key: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SandboxProvider: Send + Sync {
|
||||
fn kind(&self) -> SandboxProviderKind;
|
||||
|
||||
async fn list(&self) -> crate::Result<Vec<SandboxInfo>>;
|
||||
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>>;
|
||||
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo>;
|
||||
async fn delete(&self, id: &str) -> crate::Result<()>;
|
||||
}
|
||||
|
||||
|
|
@ -168,12 +136,6 @@ impl SandboxProvider for LocalSandboxProvider {
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
|
||||
Err(crate::Error::message(
|
||||
"local sandbox provider has no provider-managed inventory",
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete(&self, _id: &str) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ use async_trait::async_trait;
|
|||
use fabro_static::EnvVars;
|
||||
use fabro_types::{SandboxInfo, SandboxProviderKind};
|
||||
|
||||
use super::{SandboxCreateSpec, SandboxProvider};
|
||||
use crate::daytona::{self, DaytonaSandbox};
|
||||
use super::SandboxProvider;
|
||||
use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE};
|
||||
use crate::{Sandbox, details};
|
||||
use crate::{daytona, details};
|
||||
|
||||
const DAYTONA_LIST_PAGE_SIZE: i32 = 100;
|
||||
|
||||
|
|
@ -105,42 +104,6 @@ impl SandboxProvider for DaytonaSandboxProvider {
|
|||
)))
|
||||
}
|
||||
|
||||
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
|
||||
let SandboxCreateSpec::Daytona {
|
||||
config,
|
||||
github_app,
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
api_key,
|
||||
} = spec
|
||||
else {
|
||||
return Err(crate::Error::message(
|
||||
"Daytona sandbox provider can only create Daytona sandboxes",
|
||||
));
|
||||
};
|
||||
|
||||
let api_key = api_key.or_else(|| self.api_key.clone()).ok_or_else(|| {
|
||||
crate::Error::message(format!("{} is not configured", EnvVars::DAYTONA_API_KEY))
|
||||
})?;
|
||||
let sandbox = DaytonaSandbox::new(
|
||||
config.as_ref().clone(),
|
||||
github_app,
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
None,
|
||||
None,
|
||||
Some(api_key),
|
||||
)
|
||||
.await?;
|
||||
sandbox.initialize().await?;
|
||||
let sdk_sandbox = sandbox.sandbox_handle().ok_or_else(|| {
|
||||
crate::Error::message("Daytona sandbox was created but no SDK handle is available")
|
||||
})?;
|
||||
Ok(details::daytona::daytona_info_from_sdk_sandbox(sdk_sandbox))
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> crate::Result<()> {
|
||||
let client = self.client().await?;
|
||||
let sandbox = match client.get(id).await {
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bollard::Docker;
|
||||
use bollard::container::{InspectContainerOptions, ListContainersOptions, RemoveContainerOptions};
|
||||
use bollard::errors::Error as DockerError;
|
||||
use bollard::models::ContainerInspectResponse;
|
||||
use fabro_types::{SandboxInfo, SandboxProviderKind};
|
||||
use futures::future::try_join_all;
|
||||
|
||||
use super::{SandboxCreateSpec, SandboxProvider};
|
||||
use crate::docker::DockerSandbox;
|
||||
use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE};
|
||||
use crate::{Sandbox, details};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DockerSandboxProvider;
|
||||
|
||||
impl DockerSandboxProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub async fn check_daemon() -> crate::Result<()> {
|
||||
let docker = Self::docker_client()?;
|
||||
docker
|
||||
.ping()
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to reach Docker daemon", err))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn docker_client() -> crate::Result<Docker> {
|
||||
Docker::connect_with_local_defaults().map_err(crate::Error::docker_connect)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SandboxProvider for DockerSandboxProvider {
|
||||
fn kind(&self) -> SandboxProviderKind {
|
||||
SandboxProviderKind::DOCKER
|
||||
}
|
||||
|
||||
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
|
||||
let docker = Self::docker_client()?;
|
||||
let mut filters = HashMap::new();
|
||||
filters.insert("label".to_string(), vec![format!(
|
||||
"{MANAGED_LABEL}={MANAGED_LABEL_VALUE}"
|
||||
)]);
|
||||
let options = ListContainersOptions::<String> {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
};
|
||||
let containers = docker
|
||||
.list_containers(Some(options))
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to list Docker containers", err))?;
|
||||
|
||||
let ids: Vec<String> = containers.into_iter().filter_map(|c| c.id).collect();
|
||||
// Daemon-side label filter already restricts to managed containers, so we
|
||||
// can skip the per-inspect managed re-check. Run inspects concurrently on
|
||||
// the shared Docker client to avoid a serial N+1 round-trip.
|
||||
let inspects = try_join_all(
|
||||
ids.iter()
|
||||
.map(|id| docker.inspect_container(id, None::<InspectContainerOptions>)),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to inspect Docker container", err))?;
|
||||
Ok(inspects
|
||||
.iter()
|
||||
.map(details::docker::docker_info_from_inspect)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>> {
|
||||
let docker = Self::docker_client()?;
|
||||
let Some(inspect) = inspect_container(&docker, id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !managed_from_inspect(&inspect) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(details::docker::docker_info_from_inspect(&inspect)))
|
||||
}
|
||||
|
||||
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
|
||||
let SandboxCreateSpec::Docker {
|
||||
config,
|
||||
github_app,
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
} = spec
|
||||
else {
|
||||
return Err(crate::Error::message(
|
||||
"Docker sandbox provider can only create Docker sandboxes",
|
||||
));
|
||||
};
|
||||
|
||||
let sandbox = DockerSandbox::new(
|
||||
config,
|
||||
github_app.as_ref(),
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
sandbox.initialize().await?;
|
||||
let container_id = sandbox.container_identifier()?.to_string();
|
||||
self.get(&container_id).await?.ok_or_else(|| {
|
||||
crate::Error::message(format!(
|
||||
"Docker sandbox '{container_id}' was created but is not visible in provider inventory"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> crate::Result<()> {
|
||||
let docker = Self::docker_client()?;
|
||||
let Some(inspect) = inspect_container(&docker, id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !managed_from_inspect(&inspect) {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Refusing to delete Docker container '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}"
|
||||
)));
|
||||
}
|
||||
|
||||
let container_id = inspect.id.as_deref().unwrap_or(id);
|
||||
docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::Error::context(
|
||||
format!("Failed to remove Docker container '{container_id}'"),
|
||||
err,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn inspect_container(
|
||||
docker: &Docker,
|
||||
id: &str,
|
||||
) -> crate::Result<Option<ContainerInspectResponse>> {
|
||||
match docker
|
||||
.inspect_container(id, None::<InspectContainerOptions>)
|
||||
.await
|
||||
{
|
||||
Ok(inspect) => Ok(Some(inspect)),
|
||||
Err(err) if docker_not_found(&err) => Ok(None),
|
||||
Err(err) => Err(crate::Error::context(
|
||||
format!("Failed to inspect Docker container '{id}'"),
|
||||
err,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_from_inspect(inspect: &ContainerInspectResponse) -> bool {
|
||||
inspect
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.labels.as_ref())
|
||||
.is_some_and(managed_labels::is_managed)
|
||||
}
|
||||
|
||||
fn docker_not_found(error: &DockerError) -> bool {
|
||||
matches!(error, DockerError::DockerResponseServerError {
|
||||
status_code: 404,
|
||||
..
|
||||
})
|
||||
}
|
||||
246
lib/components/fabro-sandbox/src/provider/driver.rs
Normal file
246
lib/components/fabro-sandbox/src/provider/driver.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
//! Fabro-managed inventory over a sandbox-driver provider.
|
||||
//!
|
||||
//! Lists and looks up the sandboxes fabro created, identified by fabro's
|
||||
//! own `sh.fabro.managed` label. The driver marks every sandbox it creates
|
||||
//! with its own label too, but that covers every application on the same
|
||||
//! daemon or account; fabro filters on its label and refuses to delete a
|
||||
//! sandbox that does not carry it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_types::settings::server::ServerSandboxProviderSettings;
|
||||
use fabro_types::{SandboxInfo, SandboxProviderKind};
|
||||
use sandbox_driver::{SandboxFilter, SandboxId, SandboxProvider as DriverProvider};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use super::SandboxProvider;
|
||||
use crate::details;
|
||||
use crate::driver::{ConnectedProvider, ProviderConnectOptions, connect_provider};
|
||||
use crate::managed_labels::{MANAGED_LABEL, MANAGED_LABEL_VALUE};
|
||||
|
||||
/// How the driver provider behind the inventory is obtained.
|
||||
enum Connection {
|
||||
Connected(Arc<dyn DriverProvider>),
|
||||
/// Connected on first use, so a registry can be assembled synchronously
|
||||
/// and a provider that is down surfaces as a lookup error rather than a
|
||||
/// startup failure.
|
||||
Lazy(Box<LazyConnection>),
|
||||
}
|
||||
|
||||
struct LazyConnection {
|
||||
settings: ServerSandboxProviderSettings,
|
||||
options: ProviderConnectOptions,
|
||||
provider: OnceCell<Arc<dyn DriverProvider>>,
|
||||
}
|
||||
|
||||
pub struct DriverInventoryProvider {
|
||||
kind: SandboxProviderKind,
|
||||
connection: Connection,
|
||||
}
|
||||
|
||||
impl DriverInventoryProvider {
|
||||
#[must_use]
|
||||
pub fn new(connected: ConnectedProvider) -> Self {
|
||||
Self {
|
||||
kind: connected.kind,
|
||||
connection: Connection::Connected(connected.provider),
|
||||
}
|
||||
}
|
||||
|
||||
/// An inventory over a provider connected through
|
||||
/// [`connect_provider`] on first use.
|
||||
#[must_use]
|
||||
pub fn lazy(
|
||||
kind: SandboxProviderKind,
|
||||
settings: ServerSandboxProviderSettings,
|
||||
options: ProviderConnectOptions,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
connection: Connection::Lazy(Box::new(LazyConnection {
|
||||
settings,
|
||||
options,
|
||||
provider: OnceCell::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn provider(&self) -> crate::Result<&Arc<dyn DriverProvider>> {
|
||||
match &self.connection {
|
||||
Connection::Connected(provider) => Ok(provider),
|
||||
Connection::Lazy(lazy) => {
|
||||
lazy.provider
|
||||
.get_or_try_init(|| async {
|
||||
connect_provider(&self.kind, &lazy.settings, &lazy.options)
|
||||
.await
|
||||
.map(|connected| connected.provider)
|
||||
.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!("Failed to connect to the {} provider", self.kind),
|
||||
error,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_filter() -> SandboxFilter {
|
||||
let mut filter = SandboxFilter::default();
|
||||
filter
|
||||
.labels
|
||||
.insert(MANAGED_LABEL.to_string(), MANAGED_LABEL_VALUE.to_string());
|
||||
filter
|
||||
}
|
||||
|
||||
fn is_managed(labels: &BTreeMap<String, String>) -> bool {
|
||||
labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE)
|
||||
}
|
||||
|
||||
async fn describe_managed(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> crate::Result<Option<sandbox_driver::SandboxStatus>> {
|
||||
// An id the driver cannot even name is not one of ours.
|
||||
let Ok(sandbox_id) = SandboxId::try_new(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let handle = match self.provider().await?.attach(&sandbox_id, None).await {
|
||||
Ok(handle) => handle,
|
||||
Err(sandbox_driver::Error::NotFound { .. }) => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(crate::Error::context(
|
||||
format!("Failed to look up {} sandbox '{id}'", self.kind),
|
||||
error,
|
||||
));
|
||||
}
|
||||
};
|
||||
let status = handle.describe().await.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!("Failed to describe {} sandbox '{id}'", self.kind),
|
||||
error,
|
||||
)
|
||||
})?;
|
||||
if status.state == sandbox_driver::SandboxState::Deleted
|
||||
|| !Self::is_managed(&status.labels)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(status))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SandboxProvider for DriverInventoryProvider {
|
||||
fn kind(&self) -> SandboxProviderKind {
|
||||
self.kind.clone()
|
||||
}
|
||||
|
||||
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
|
||||
let statuses = self
|
||||
.provider()
|
||||
.await?
|
||||
.list(&Self::managed_filter())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::context(format!("Failed to list {} sandboxes", self.kind), error)
|
||||
})?;
|
||||
Ok(statuses
|
||||
.iter()
|
||||
// The filter is a request; a provider that cannot filter on
|
||||
// labels returns everything, so the label is checked again.
|
||||
.filter(|status| Self::is_managed(&status.labels))
|
||||
.map(|status| details::info_from_status(&self.kind, status))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>> {
|
||||
Ok(self
|
||||
.describe_managed(id)
|
||||
.await?
|
||||
.map(|status| details::info_from_status(&self.kind, &status)))
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> crate::Result<()> {
|
||||
let Some(status) = self.describe_managed(id).await? else {
|
||||
// Missing, already deleted, or not fabro's: the first two are
|
||||
// idempotent successes and the third must never be deleted here,
|
||||
// so distinguish them for the caller.
|
||||
if let Ok(sandbox_id) = SandboxId::try_new(id) {
|
||||
if let Ok(handle) = self.provider().await?.attach(&sandbox_id, None).await {
|
||||
let status = handle.describe().await?;
|
||||
if status.state != sandbox_driver::SandboxState::Deleted {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Refusing to delete {} sandbox '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}",
|
||||
self.kind
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
self.provider()
|
||||
.await?
|
||||
.delete(&status.id, None)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!("Failed to delete {} sandbox '{id}'", self.kind),
|
||||
error,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sandbox_driver::{SandboxSource, SandboxSpec};
|
||||
use sandbox_driver_host::HostProvider;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn inventory() -> (DriverInventoryProvider, Arc<HostProvider>) {
|
||||
let host = Arc::new(HostProvider::new());
|
||||
let provider = DriverInventoryProvider::new(ConnectedProvider {
|
||||
kind: SandboxProviderKind::try_new("host").unwrap(),
|
||||
provider: host.clone(),
|
||||
});
|
||||
(provider, host)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_and_deletes_only_fabro_managed_sandboxes() {
|
||||
let (inventory, host) = inventory();
|
||||
let ours = host
|
||||
.create(
|
||||
&SandboxSpec::new(SandboxSource::HostDirectory).label(MANAGED_LABEL, "true"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let theirs = host
|
||||
.create(&SandboxSpec::new(SandboxSource::HostDirectory), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let listed = inventory.list().await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, ours.id().as_str());
|
||||
assert_eq!(listed[0].provider.as_str(), "host");
|
||||
assert!(inventory.get(ours.id().as_str()).await.unwrap().is_some());
|
||||
assert!(inventory.get(theirs.id().as_str()).await.unwrap().is_none());
|
||||
|
||||
let refused = inventory.delete(theirs.id().as_str()).await.unwrap_err();
|
||||
assert!(
|
||||
refused.to_string().contains("Refusing to delete"),
|
||||
"{refused}"
|
||||
);
|
||||
inventory.delete(ours.id().as_str()).await.unwrap();
|
||||
assert!(inventory.get(ours.id().as_str()).await.unwrap().is_none());
|
||||
inventory.delete(ours.id().as_str()).await.unwrap();
|
||||
inventory.delete("never-existed").await.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,10 @@ use std::path::PathBuf;
|
|||
use anyhow::{Context, Result, bail};
|
||||
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
|
||||
|
||||
use crate::SandboxEventCallback;
|
||||
#[cfg(feature = "daytona")]
|
||||
use crate::daytona::DaytonaSandbox;
|
||||
#[cfg(feature = "docker")]
|
||||
use crate::docker::DockerSandbox;
|
||||
use crate::driver_sandbox::local_sandbox;
|
||||
use crate::{SandboxEventCallback, docker};
|
||||
|
||||
/// Reconnect to a sandbox from a saved record.
|
||||
///
|
||||
|
|
@ -66,17 +64,15 @@ pub async fn reconnect_for_run_with_callback(
|
|||
}
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
#[cfg(feature = "docker")]
|
||||
Some(BundledProvider::Docker) => {
|
||||
let repo_cloned = runtime
|
||||
.repo_cloned
|
||||
.context("Docker run sandbox missing repo_cloned metadata")?;
|
||||
let mut sandbox = DockerSandbox::reconnect(
|
||||
let mut sandbox = docker::attach_docker(
|
||||
&runtime.id,
|
||||
repo_cloned,
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
runtime.clone_branch.clone(),
|
||||
run_id,
|
||||
)
|
||||
.await
|
||||
|
|
@ -86,8 +82,6 @@ pub async fn reconnect_for_run_with_callback(
|
|||
}
|
||||
Ok(Box::new(sandbox))
|
||||
}
|
||||
#[cfg(not(feature = "docker"))]
|
||||
Some(BundledProvider::Docker) => bail!("Docker sandbox support is not enabled"),
|
||||
#[cfg(feature = "daytona")]
|
||||
Some(BundledProvider::Daytona) => {
|
||||
let repo_cloned = runtime
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use fabro_types::{CommandOutputStream, CommandTermination};
|
|||
use fabro_util::shell;
|
||||
use fabro_util::workspace_glob::WorkspaceGlob;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time;
|
||||
|
|
@ -31,11 +31,9 @@ pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024;
|
|||
pub(crate) const BASH_PROBE_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
/// Bash path required by Linux-backed remote sandbox providers.
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
pub(crate) const REMOTE_BASH: &str = "/bin/bash";
|
||||
|
||||
/// Timeout for provider-neutral remote file traversal.
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
pub(crate) const REMOTE_WALK_TIMEOUT_MS: u64 = 30_000;
|
||||
|
||||
/// Environment variable Bash consults for non-interactive startup source.
|
||||
|
|
@ -946,42 +944,6 @@ impl<'a> ExecStreamingRequest<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn write_process_stdin<W>(mut writer: W, stdin: &[u8]) -> crate::Result<()>
|
||||
where
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
// A command that stops reading its input (`head -1`, an early exit) is
|
||||
// not an error; its exit code is the authoritative result. Local pipes
|
||||
// surface that as `BrokenPipe`, remote transports (a TCP Docker daemon)
|
||||
// as `ConnectionReset`/`ConnectionAborted`.
|
||||
fn command_stopped_reading(err: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
err.kind(),
|
||||
std::io::ErrorKind::BrokenPipe
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
)
|
||||
}
|
||||
|
||||
if let Err(err) = writer.write_all(stdin).await {
|
||||
if !command_stopped_reading(&err) {
|
||||
return Err(crate::Error::context(
|
||||
"Failed to write command standard input",
|
||||
err,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Err(err) = writer.shutdown().await {
|
||||
if !command_stopped_reading(&err) {
|
||||
return Err(crate::Error::context(
|
||||
"Failed to close command standard input",
|
||||
err,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn replay_exec_result(
|
||||
mut result: ExecResult,
|
||||
streams_separated: bool,
|
||||
|
|
@ -1603,7 +1565,6 @@ pub(crate) fn join_sandbox_path(base: &str, relative_path: &str) -> String {
|
|||
format!("{}/{relative_path}", base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
pub(crate) fn build_remote_walk_command(
|
||||
base: &str,
|
||||
relative_start: &str,
|
||||
|
|
@ -1637,7 +1598,6 @@ pub(crate) fn build_remote_walk_command(
|
|||
command
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
pub(crate) fn parse_remote_walk_output(
|
||||
base: &str,
|
||||
relative_start: &str,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ use std::path::PathBuf;
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context as _;
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
use fabro_github::GitHubCredentials;
|
||||
#[allow(
|
||||
unused_imports,
|
||||
|
|
@ -10,21 +9,17 @@ use fabro_github::GitHubCredentials;
|
|||
)]
|
||||
use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
use crate::clone_source;
|
||||
#[cfg(feature = "daytona")]
|
||||
use crate::daytona::{self, DaytonaConfig, DaytonaSandbox};
|
||||
#[cfg(feature = "docker")]
|
||||
use crate::docker::{self, DockerSandbox, DockerSandboxOptions};
|
||||
use crate::docker::{self, DockerSandboxOptions};
|
||||
use crate::driver_sandbox::local_sandbox;
|
||||
use crate::{Sandbox, SandboxEventCallback};
|
||||
use crate::{Sandbox, SandboxEventCallback, clone_source};
|
||||
|
||||
/// Options for sandbox initialization and construction.
|
||||
pub enum SandboxSpec {
|
||||
Local {
|
||||
working_directory: PathBuf,
|
||||
},
|
||||
#[cfg(feature = "docker")]
|
||||
Docker {
|
||||
config: DockerSandboxOptions,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
|
|
@ -51,7 +46,6 @@ impl SandboxSpec {
|
|||
pub fn provider(&self) -> SandboxProviderKind {
|
||||
match self {
|
||||
Self::Local { .. } => SandboxProviderKind::LOCAL,
|
||||
#[cfg(feature = "docker")]
|
||||
Self::Docker { .. } => SandboxProviderKind::DOCKER,
|
||||
#[cfg(feature = "daytona")]
|
||||
Self::Daytona { .. } => SandboxProviderKind::DAYTONA,
|
||||
|
|
@ -61,7 +55,6 @@ impl SandboxSpec {
|
|||
pub fn provider_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Local { .. } => "local",
|
||||
#[cfg(feature = "docker")]
|
||||
Self::Docker { .. } => "docker",
|
||||
#[cfg(feature = "daytona")]
|
||||
Self::Daytona { .. } => "daytona",
|
||||
|
|
@ -85,7 +78,6 @@ impl SandboxSpec {
|
|||
};
|
||||
|
||||
match self {
|
||||
#[cfg(feature = "docker")]
|
||||
Self::Docker {
|
||||
config,
|
||||
clone_origin_url,
|
||||
|
|
@ -198,7 +190,6 @@ impl SandboxSpec {
|
|||
}
|
||||
Ok(Arc::new(sandbox))
|
||||
}
|
||||
#[cfg(feature = "docker")]
|
||||
Self::Docker {
|
||||
config,
|
||||
github_app,
|
||||
|
|
@ -208,7 +199,7 @@ impl SandboxSpec {
|
|||
clone_tag,
|
||||
clone_commit_sha,
|
||||
} => {
|
||||
let mut sandbox = DockerSandbox::new(
|
||||
let mut sandbox = docker::docker_sandbox(
|
||||
config.clone(),
|
||||
github_app.as_ref(),
|
||||
*run_id,
|
||||
|
|
@ -217,6 +208,7 @@ impl SandboxSpec {
|
|||
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);
|
||||
|
|
@ -255,7 +247,6 @@ impl SandboxSpec {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "docker", feature = "daytona"))]
|
||||
fn runtime_layout_metadata(
|
||||
repo_cloned: Option<bool>,
|
||||
clone_origin_url: Option<&str>,
|
||||
|
|
@ -270,15 +261,11 @@ fn runtime_layout_metadata(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(feature = "docker")]
|
||||
use fabro_types::RunId;
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
use super::*;
|
||||
#[cfg(feature = "docker")]
|
||||
use crate::test_support::MockSandbox;
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[test]
|
||||
fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() {
|
||||
let spec = SandboxSpec::Docker {
|
||||
|
|
@ -317,7 +304,6 @@ mod tests {
|
|||
assert!(runtime_json.get("clone_commit_sha").is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[tokio::test]
|
||||
async fn invalid_exact_checkout_spec_fails_before_provider_connection() {
|
||||
let spec = SandboxSpec::Docker {
|
||||
|
|
@ -344,7 +330,6 @@ mod tests {
|
|||
assert!(!format!("{error:#}").contains("Docker daemon"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[test]
|
||||
fn docker_run_sandbox_omits_primary_repo_metadata_for_empty_workspace() {
|
||||
let spec = SandboxSpec::Docker {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,9 @@ use async_trait::async_trait;
|
|||
use fabro_static::EnvVars;
|
||||
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
|
||||
|
||||
#[cfg(any(feature = "daytona", feature = "docker"))]
|
||||
use crate::Sandbox;
|
||||
#[cfg(feature = "daytona")]
|
||||
use crate::daytona::{DEFAULT_DAYTONA_API_URL, DaytonaSandbox};
|
||||
#[cfg(feature = "docker")]
|
||||
use crate::docker::DockerSandbox;
|
||||
use crate::{Sandbox, docker};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TerminalSize {
|
||||
|
|
@ -33,6 +30,52 @@ pub trait TerminalSession: Send + Sync {
|
|||
async fn close(&self) -> crate::Result<()>;
|
||||
}
|
||||
|
||||
/// A terminal over the driver's Pty facet.
|
||||
pub struct DriverTerminalSession {
|
||||
session: Box<dyn sandbox_driver::PtySession>,
|
||||
}
|
||||
|
||||
impl DriverTerminalSession {
|
||||
#[must_use]
|
||||
pub fn new(session: Box<dyn sandbox_driver::PtySession>) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TerminalSession for DriverTerminalSession {
|
||||
async fn write_input(&self, bytes: &[u8]) -> crate::Result<()> {
|
||||
self.session
|
||||
.write_input(bytes)
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to write terminal input", err))
|
||||
}
|
||||
|
||||
async fn read_output(&self) -> crate::Result<Option<Vec<u8>>> {
|
||||
self.session
|
||||
.read_output()
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to read terminal output", err))
|
||||
}
|
||||
|
||||
async fn resize(&self, size: TerminalSize) -> crate::Result<()> {
|
||||
self.session
|
||||
.resize(sandbox_driver::PtySize {
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to resize terminal", err))
|
||||
}
|
||||
|
||||
async fn close(&self) -> crate::Result<()> {
|
||||
self.session
|
||||
.close()
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to close terminal", err))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_terminal_for_run(
|
||||
record: &RunSandboxInstance,
|
||||
daytona_api_key: Option<String>,
|
||||
|
|
@ -40,14 +83,9 @@ pub async fn open_terminal_for_run(
|
|||
run_id: Option<RunId>,
|
||||
size: TerminalSize,
|
||||
) -> crate::Result<Box<dyn TerminalSession>> {
|
||||
#[cfg(any(feature = "daytona", feature = "docker"))]
|
||||
let runtime = &record.runtime;
|
||||
#[cfg(not(feature = "daytona"))]
|
||||
let _ = (&daytona_api_key, &daytona_organization_id);
|
||||
#[cfg(not(feature = "docker"))]
|
||||
let _ = &run_id;
|
||||
#[cfg(not(any(feature = "daytona", feature = "docker")))]
|
||||
let _ = size;
|
||||
|
||||
match record.provider.bundled() {
|
||||
#[cfg(feature = "daytona")]
|
||||
|
|
@ -81,28 +119,21 @@ pub async fn open_terminal_for_run(
|
|||
Some(BundledProvider::Daytona) => Err(crate::Error::message(
|
||||
"Daytona sandbox support is not enabled",
|
||||
)),
|
||||
#[cfg(feature = "docker")]
|
||||
Some(BundledProvider::Docker) => {
|
||||
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
|
||||
crate::Error::message("Docker run sandbox is missing clone metadata")
|
||||
})?;
|
||||
let sandbox = DockerSandbox::reconnect(
|
||||
let sandbox = docker::attach_docker(
|
||||
&runtime.id,
|
||||
repo_cloned,
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
runtime.clone_branch.clone(),
|
||||
run_id,
|
||||
)
|
||||
.await?;
|
||||
sandbox.activate().await?;
|
||||
let session = DockerTerminalSession::open(&sandbox, size).await?;
|
||||
Ok(Box::new(session))
|
||||
Ok(Box::new(sandbox.open_terminal(size).await?))
|
||||
}
|
||||
#[cfg(not(feature = "docker"))]
|
||||
Some(BundledProvider::Docker) => Err(crate::Error::message(
|
||||
"Docker sandbox support is not enabled",
|
||||
)),
|
||||
Some(BundledProvider::Local) => Err(crate::Error::message(
|
||||
"Local sandboxes do not support embedded terminals",
|
||||
)),
|
||||
|
|
@ -680,213 +711,3 @@ mod daytona_terminal {
|
|||
|
||||
#[cfg(feature = "daytona")]
|
||||
use daytona_terminal::DaytonaTerminalSession;
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
mod docker_terminal {
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bollard::Docker;
|
||||
use bollard::container::LogOutput;
|
||||
use bollard::errors::Error as DockerError;
|
||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||
use futures::{Stream, StreamExt};
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::{TerminalSession, TerminalSize};
|
||||
use crate::Sandbox;
|
||||
use crate::docker::DockerSandbox;
|
||||
|
||||
type DockerInput = Pin<Box<dyn AsyncWrite + Send>>;
|
||||
type DockerOutput = Pin<Box<dyn Stream<Item = Result<LogOutput, DockerError>> + Send>>;
|
||||
|
||||
pub(super) struct DockerTerminalSession {
|
||||
docker: Docker,
|
||||
container_id: String,
|
||||
exec_id: String,
|
||||
pid_file: String,
|
||||
input: Mutex<Option<DockerInput>>,
|
||||
output: Mutex<Option<DockerOutput>>,
|
||||
closed: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl DockerTerminalSession {
|
||||
pub(super) async fn open(
|
||||
sandbox: &DockerSandbox,
|
||||
size: TerminalSize,
|
||||
) -> crate::Result<Self> {
|
||||
let docker = sandbox.docker_client();
|
||||
let container_id = sandbox.container_identifier()?.to_string();
|
||||
let pid_file = format!("/tmp/fabro-terminal-{}.pid", uuid_fragment());
|
||||
let exec_opts = docker_terminal_exec_options(sandbox.working_directory(), &pid_file);
|
||||
let exec = docker
|
||||
.create_exec(&container_id, exec_opts)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::Error::context("Failed to create Docker terminal exec", err)
|
||||
})?;
|
||||
let exec_id = exec.id;
|
||||
let start = docker.start_exec(&exec_id, None).await.map_err(|err| {
|
||||
crate::Error::context("Failed to start Docker terminal exec", err)
|
||||
})?;
|
||||
let StartExecResults::Attached { output, input } = start else {
|
||||
return Err(crate::Error::message("Docker terminal exec did not attach"));
|
||||
};
|
||||
docker
|
||||
.resize_exec(&exec_id, ResizeExecOptions {
|
||||
height: size.rows,
|
||||
width: size.cols,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::Error::context("Failed to resize Docker terminal exec", err)
|
||||
})?;
|
||||
Ok(Self {
|
||||
docker,
|
||||
container_id,
|
||||
exec_id,
|
||||
pid_file,
|
||||
input: Mutex::new(Some(input)),
|
||||
output: Mutex::new(Some(output)),
|
||||
closed: Mutex::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
async fn kill_shell(&self) -> crate::Result<()> {
|
||||
let command = format!(
|
||||
"if [ -f {pid_file} ]; then kill -TERM \"$(cat {pid_file})\" 2>/dev/null || true; rm -f {pid_file}; fi",
|
||||
pid_file = crate::shell_quote(&self.pid_file),
|
||||
);
|
||||
let exec = self
|
||||
.docker
|
||||
.create_exec(&self.container_id, CreateExecOptions {
|
||||
cmd: Some(vec!["sh".to_string(), "-lc".to_string(), command]),
|
||||
attach_stdout: Some(false),
|
||||
attach_stderr: Some(false),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::Error::context("Failed to create Docker terminal cleanup exec", err)
|
||||
})?;
|
||||
self.docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::Error::context("Failed to run Docker terminal cleanup exec", err)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TerminalSession for DockerTerminalSession {
|
||||
async fn write_input(&self, bytes: &[u8]) -> crate::Result<()> {
|
||||
let mut input = self.input.lock().await;
|
||||
let Some(input) = input.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
input
|
||||
.write_all(bytes)
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to write Docker terminal input", err))
|
||||
}
|
||||
|
||||
async fn read_output(&self) -> crate::Result<Option<Vec<u8>>> {
|
||||
let mut output = self.output.lock().await;
|
||||
let Some(output) = output.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match output.next().await {
|
||||
Some(Ok(chunk)) => Ok(Some(chunk.into_bytes().to_vec())),
|
||||
Some(Err(err)) => Err(crate::Error::context(
|
||||
"Failed to read Docker terminal output",
|
||||
err,
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resize(&self, size: TerminalSize) -> crate::Result<()> {
|
||||
self.docker
|
||||
.resize_exec(&self.exec_id, ResizeExecOptions {
|
||||
height: size.rows,
|
||||
width: size.cols,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| crate::Error::context("Failed to resize Docker terminal exec", err))
|
||||
}
|
||||
|
||||
async fn close(&self) -> crate::Result<()> {
|
||||
let mut closed = self.closed.lock().await;
|
||||
if *closed {
|
||||
return Ok(());
|
||||
}
|
||||
*closed = true;
|
||||
drop(closed);
|
||||
let _ = self.input.lock().await.take();
|
||||
let _ = self.output.lock().await.take();
|
||||
self.kill_shell().await
|
||||
}
|
||||
}
|
||||
|
||||
fn docker_terminal_exec_options(
|
||||
working_directory: &str,
|
||||
pid_file: &str,
|
||||
) -> CreateExecOptions<String> {
|
||||
let command = format!(
|
||||
"printf '%s\\n' $$ > {pid_file}; exec sh -l",
|
||||
pid_file = crate::shell_quote(pid_file),
|
||||
);
|
||||
CreateExecOptions {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(true),
|
||||
cmd: Some(vec!["sh".to_string(), "-lc".to_string(), command]),
|
||||
working_dir: Some(working_directory.to_string()),
|
||||
env: Some(vec![
|
||||
"TERM=xterm-256color".to_string(),
|
||||
"LANG=C.UTF-8".to_string(),
|
||||
]),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
static DOCKER_TERMINAL_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
fn uuid_fragment() -> String {
|
||||
format!(
|
||||
"{:016x}",
|
||||
DOCKER_TERMINAL_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn docker_terminal_exec_options_attach_tty_and_workspace_env() {
|
||||
let options = docker_terminal_exec_options("/workspace", "/tmp/fabro-terminal.pid");
|
||||
assert_eq!(options.attach_stdin, Some(true));
|
||||
assert_eq!(options.attach_stdout, Some(true));
|
||||
assert_eq!(options.attach_stderr, Some(true));
|
||||
assert_eq!(options.tty, Some(true));
|
||||
assert_eq!(options.working_dir.as_deref(), Some("/workspace"));
|
||||
assert_eq!(
|
||||
options.env,
|
||||
Some(vec![
|
||||
"TERM=xterm-256color".to_string(),
|
||||
"LANG=C.UTF-8".to_string()
|
||||
])
|
||||
);
|
||||
assert!(options.cmd.unwrap().join(" ").contains("exec sh -l"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
use docker_terminal::DockerTerminalSession;
|
||||
|
|
|
|||
|
|
@ -741,7 +741,7 @@ mod fake_provider {
|
|||
SandboxTimestamps,
|
||||
};
|
||||
|
||||
use crate::provider::{SandboxCreateSpec, SandboxProvider, SandboxProviderRegistry};
|
||||
use crate::provider::{SandboxProvider, SandboxProviderRegistry};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum FakeList {
|
||||
|
|
@ -789,10 +789,6 @@ mod fake_provider {
|
|||
}
|
||||
}
|
||||
|
||||
async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
|
||||
Err(crate::Error::message("not implemented"))
|
||||
}
|
||||
|
||||
async fn delete(&self, _id: &str) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
#![cfg(feature = "docker")]
|
||||
//! Docker sandbox behaviour through the sandbox-driver Docker provider.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bollard::Docker;
|
||||
use fabro_sandbox::{
|
||||
CommandOutputCallback, DockerSandbox, DockerSandboxOptions, ExecStreamingRequest, Sandbox,
|
||||
CommandOutputCallback, DockerSandboxOptions, ExecStreamingRequest, Sandbox, docker_sandbox,
|
||||
};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Whether a Docker daemon answers and has `image` locally. The tests are
|
||||
/// skipped (not failed) otherwise, matching the ignore reason.
|
||||
async fn docker_image_available(image: &str) -> bool {
|
||||
Command::new("docker")
|
||||
.args(["image", "inspect", image])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn capture_bytes(chunks: Arc<Mutex<Vec<u8>>>) -> CommandOutputCallback {
|
||||
Arc::new(move |_stream, bytes| {
|
||||
let chunks = Arc::clone(&chunks);
|
||||
|
|
@ -22,14 +34,11 @@ fn capture_bytes(chunks: Arc<Mutex<Vec<u8>>>) -> CommandOutputCallback {
|
|||
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
|
||||
async fn streaming_timeout_terminates_docker_exec_before_returning() {
|
||||
let image = "buildpack-deps:noble";
|
||||
let Ok(docker) = Docker::connect_with_local_defaults() else {
|
||||
return;
|
||||
};
|
||||
if docker.inspect_image(image).await.is_err() {
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = DockerSandbox::new(
|
||||
let sandbox = docker_sandbox(
|
||||
DockerSandboxOptions {
|
||||
image: image.to_string(),
|
||||
auto_pull: false,
|
||||
|
|
@ -43,6 +52,7 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
|
|||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
|
|
@ -96,14 +106,11 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
|
|||
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
|
||||
async fn streaming_command_receives_exact_stdin_and_eof() {
|
||||
let image = "buildpack-deps:noble";
|
||||
let Ok(docker) = Docker::connect_with_local_defaults() else {
|
||||
return;
|
||||
};
|
||||
if docker.inspect_image(image).await.is_err() {
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = DockerSandbox::new(
|
||||
let sandbox = docker_sandbox(
|
||||
DockerSandboxOptions {
|
||||
image: image.to_string(),
|
||||
auto_pull: false,
|
||||
|
|
@ -117,6 +124,7 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
|
|||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
|
|
@ -159,14 +167,11 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
|
|||
#[ignore = "requires real Docker container lifecycle, image, network, and a public GitHub clone"]
|
||||
async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
|
||||
let image = "buildpack-deps:noble";
|
||||
let Ok(docker) = Docker::connect_with_local_defaults() else {
|
||||
return;
|
||||
};
|
||||
if docker.inspect_image(image).await.is_err() {
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = DockerSandbox::new(
|
||||
let sandbox = docker_sandbox(
|
||||
DockerSandboxOptions {
|
||||
image: image.to_string(),
|
||||
auto_pull: false,
|
||||
|
|
@ -180,6 +185,7 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
|
|||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
|
|
@ -226,14 +232,11 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
|
|||
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
|
||||
async fn docker_runs_clean_bash_through_both_command_paths() {
|
||||
let image = "buildpack-deps:noble";
|
||||
let Ok(docker) = Docker::connect_with_local_defaults() else {
|
||||
return;
|
||||
};
|
||||
if docker.inspect_image(image).await.is_err() {
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = DockerSandbox::new(
|
||||
let sandbox = docker_sandbox(
|
||||
DockerSandboxOptions {
|
||||
image: image.to_string(),
|
||||
auto_pull: false,
|
||||
|
|
@ -248,6 +251,7 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
|
|||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
|
|
@ -322,14 +326,11 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
|
|||
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Sandbox::glob"]
|
||||
async fn docker_glob_matches_patterns_containing_a_path_separator() {
|
||||
let image = "buildpack-deps:noble";
|
||||
let Ok(docker) = Docker::connect_with_local_defaults() else {
|
||||
return;
|
||||
};
|
||||
if docker.inspect_image(image).await.is_err() {
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = DockerSandbox::new(
|
||||
let sandbox = docker_sandbox(
|
||||
DockerSandboxOptions {
|
||||
image: image.to_string(),
|
||||
auto_pull: false,
|
||||
|
|
@ -343,6 +344,7 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
|
|||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
|
|
@ -408,14 +410,11 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
|
|||
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker runtime directory setup"]
|
||||
async fn docker_runtime_directory_is_private_and_outside_workspace() {
|
||||
let image = "buildpack-deps:noble";
|
||||
let Ok(docker) = Docker::connect_with_local_defaults() else {
|
||||
return;
|
||||
};
|
||||
if docker.inspect_image(image).await.is_err() {
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = DockerSandbox::new(
|
||||
let sandbox = docker_sandbox(
|
||||
DockerSandboxOptions {
|
||||
image: image.to_string(),
|
||||
auto_pull: false,
|
||||
|
|
@ -429,6 +428,7 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() {
|
|||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
//! Three comparisons, each over the same medium repository (fabro's own
|
||||
//! `lib/` tree, about 1,100 Rust files):
|
||||
//!
|
||||
//! - Docker file reads and content search: fabro's `DockerSandbox` (archive API
|
||||
//! reads, `docker exec` grep) against the driver `DockerProvider` (archive
|
||||
//! API reads, exec-derived search) in-process.
|
||||
//! - Docker file reads and content search: fabro's driver-backed Docker sandbox
|
||||
//! (its path resolution and result shaping) against the bare driver
|
||||
//! `DockerProvider` in-process.
|
||||
//! - Host tool calls: fabro's local sandbox against the driver `HostProvider`
|
||||
//! in-process, to confirm no regression on the path every local run takes.
|
||||
//! - The wire: the driver Host and Docker providers served over the JSON-RPC
|
||||
|
|
@ -15,10 +15,9 @@
|
|||
//!
|
||||
//! Ignored: it needs a Docker daemon with `buildpack-deps:noble` present and
|
||||
//! takes a minute. Run with
|
||||
//! `cargo nextest run -p fabro-sandbox --features docker --test driver_bench
|
||||
//! --run-ignored only --no-capture`.
|
||||
//! `cargo nextest run -p fabro-sandbox --test driver_bench --run-ignored only
|
||||
//! --no-capture`.
|
||||
|
||||
#![cfg(feature = "docker")]
|
||||
#![allow(
|
||||
clippy::print_stderr,
|
||||
clippy::cast_precision_loss,
|
||||
|
|
@ -36,8 +35,7 @@ use std::process::Command;
|
|||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bollard::Docker;
|
||||
use fabro_sandbox::{DockerSandbox, DockerSandboxOptions, Sandbox as FabroSandbox, local_sandbox};
|
||||
use fabro_sandbox::{DockerSandboxOptions, Sandbox as FabroSandbox, docker_sandbox, local_sandbox};
|
||||
use sandbox_driver::{
|
||||
ExecSpec, GrepOptions, Sandbox as DriverSandbox, SandboxProvider, SandboxSource, SandboxSpec,
|
||||
Search,
|
||||
|
|
@ -324,12 +322,13 @@ async fn serve_over_duplex(provider: Arc<dyn SandboxProvider>) -> PluginProvider
|
|||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "benchmark: needs a Docker daemon with buildpack-deps:noble and takes about a minute"]
|
||||
async fn agent_tool_call_latency_through_the_driver() {
|
||||
let Ok(docker) = Docker::connect_with_local_defaults() else {
|
||||
eprintln!("no Docker daemon; skipping");
|
||||
return;
|
||||
};
|
||||
if docker.inspect_image(IMAGE).await.is_err() {
|
||||
eprintln!("{IMAGE} is not present locally; skipping");
|
||||
let image_check = Command::new("docker")
|
||||
.args(["image", "inspect", IMAGE])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
if !image_check.is_ok_and(|status| status.success()) {
|
||||
eprintln!("no Docker daemon or {IMAGE} is not present locally; skipping");
|
||||
return;
|
||||
}
|
||||
let repo = Repository::pack();
|
||||
|
|
@ -363,8 +362,8 @@ async fn agent_tool_call_latency_through_the_driver() {
|
|||
remote_host.shutdown().await.expect("shutdown");
|
||||
host.delete().await.expect("host delete");
|
||||
|
||||
// -- Docker, in-process: fabro DockerSandbox vs driver DockerProvider.
|
||||
let fabro_docker = DockerSandbox::new(
|
||||
// -- 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,
|
||||
|
|
@ -378,10 +377,11 @@ async fn agent_tool_call_latency_through_the_driver() {
|
|||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("fabro docker sandbox");
|
||||
fabro_docker.initialize().await.expect("fabro docker init");
|
||||
unpack_fabro(&fabro_docker, &repo).await;
|
||||
rows.extend(bench_fabro("fabro DockerSandbox", &fabro_docker, &repo).await);
|
||||
rows.extend(bench_fabro("fabro Docker (driver-backed)", &fabro_docker, &repo).await);
|
||||
fabro_docker.cleanup().await.expect("fabro docker cleanup");
|
||||
|
||||
let docker_provider = Arc::new(DockerProvider::connect().await.expect("docker connect"));
|
||||
|
|
|
|||
|
|
@ -11,24 +11,3 @@ fn context_error_preserves_source_cause() {
|
|||
"Failed to read file\n caused by: permission denied"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[test]
|
||||
fn docker_image_inspect_error_preserves_source_cause() {
|
||||
use bollard::errors::Error as BollardError;
|
||||
|
||||
let source = BollardError::DockerResponseServerError {
|
||||
status_code: 500,
|
||||
message: "daemon unavailable".to_string(),
|
||||
};
|
||||
|
||||
let error = fabro_sandbox::Error::docker_image_inspect("buildpack-deps:noble", source);
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Failed to inspect Docker image buildpack-deps:noble"
|
||||
);
|
||||
assert_eq!(error.causes(), vec![
|
||||
"Docker responded with status code 500: daemon unavailable"
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
|
|||
fabro-workflow = { path = ".", features = ["test-support"] }
|
||||
fabro-api = { path = "../../foundation/fabro-api" }
|
||||
fabro-environment = { path = "../fabro-environment" }
|
||||
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker", "test-support"] }
|
||||
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "test-support"] }
|
||||
fabro-mcp = { path = "../fabro-mcp" }
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
object_store.workspace = true
|
||||
|
|
|
|||
|
|
@ -13760,7 +13760,8 @@ async fn asset_collection_docker_sandbox() {
|
|||
..Default::default()
|
||||
};
|
||||
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
|
||||
fabro_agent::DockerSandbox::new(config, None, None, None, None, None, None)
|
||||
fabro_agent::docker_sandbox(config, None, None, None, None, None, None)
|
||||
.await
|
||||
.expect("Docker not available"),
|
||||
);
|
||||
sandbox.initialize().await.expect("Docker init failed");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue