mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-03 02:24:33 +00:00
Delete fabro-sandbox
Nothing imports it any more: the Pebble glue lives in fabro-pebble-sandbox, the server reaches run sandboxes through sandbox_access, and Petri creates every run sandbox. The crate, its test-support, its integration tests and every dependency edge go with it. The `[server.sandbox.providers.<kind>.plugin]` settings stay: the server still launches a plugin executable through them to attach to a sandbox of a non-bundled kind. AGENTS.md names the new crate and the direct-access pattern in place of `RunSandbox`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
23b8a6c449
commit
f054082f86
32 changed files with 5 additions and 8539 deletions
|
|
@ -117,7 +117,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
|
||||
- **fabro-dot** — The workflow graph as written, read through Petri's DOT parser: its name, goal, node and edge counts, and the files it references (`import`, `stack.child_workflow`, `@file` prompts, the goal). The bundler and the workflow-version store walk references through it; `fabro-graphviz` re-emits Fabro DOT for Graphviz through it
|
||||
- **fabro-graphviz** — SVG rendering of workflow graphs through the vendored Graphviz (`graphviz-sys`)
|
||||
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
|
||||
- **fabro-pebble-sandbox** — A `sandbox-driver` handle as the `Environment` pebble's coding agent runs its tools through (`PebbleSandbox`), with Fabro's exec policy, port routes, and secret redactor. Petri creates and owns every run sandbox through the sandbox driver; Fabro attaches to one for Ask Fabro, and `fabro exec` creates a host sandbox of its own. Agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and runs the operator's Docker daemon; daemon access is host-root-equivalent and assumes trusted callers/payloads.
|
||||
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
|
||||
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
|
||||
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
|
||||
|
|
@ -229,7 +229,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
|
||||
- **fabro-dot** — The workflow graph as written, read through Petri's DOT parser: its name, goal, node and edge counts, and the files it references (`import`, `stack.child_workflow`, `@file` prompts, the goal). The bundler and the workflow-version store walk references through it; `fabro-graphviz` re-emits Fabro DOT for Graphviz through it
|
||||
- **fabro-graphviz** — SVG rendering of workflow graphs through the vendored Graphviz (`graphviz-sys`)
|
||||
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
|
||||
- **fabro-pebble-sandbox** — A `sandbox-driver` handle as the `Environment` pebble's coding agent runs its tools through (`PebbleSandbox`), with Fabro's exec policy, port routes, and secret redactor. Petri creates and owns every run sandbox through the sandbox driver; Fabro attaches to one for Ask Fabro, and `fabro exec` creates a host sandbox of its own. Agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and runs the operator's Docker daemon; daemon access is host-root-equivalent and assumes trusted callers/payloads.
|
||||
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
|
||||
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
|
||||
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
|
||||
|
|
@ -246,7 +246,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
- **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec
|
||||
|
||||
### Key design patterns
|
||||
- **RunSandbox** — One concrete sandbox type for local, Docker, and Daytona execution environments, over the `sandbox-driver` facets (exec, filesystem, search, git). There is no fabro-side sandbox trait; tests use `fabro_sandbox::test_support::MockSandbox` over the driver's scripted doubles. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection.
|
||||
- **Direct sandbox access** — Petri creates every run sandbox through the sandbox driver and records its provider, id and working directory on the run (`RunSandboxInstance`); every Docker and Daytona sandbox carries the `petri.run` label. The server reaches a run's sandbox (the sandbox tab, Run Files, terminal, SSH, preview URLs, VNC, `fabro cp`, Ask Fabro, deletion) through `fabro-server/src/sandbox_access.rs`: it connects the record's provider itself, keys ownership on `petri.run`, and works on the driver's `Arc<dyn Sandbox>` facets (exec, filesystem, search, git, pty). There is no fabro-side sandbox trait; tests use `fabro_pebble_sandbox::test_support::MockSandbox` over the driver's scripted doubles.
|
||||
- **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes
|
||||
- **OpenAPI-first** — `fabro-api.yaml` drives Rust type + client generation (progenitor) and TypeScript client generation (openapi-generator)
|
||||
- **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed
|
||||
|
|
|
|||
34
Cargo.lock
generated
34
Cargo.lock
generated
|
|
@ -2112,7 +2112,6 @@ dependencies = [
|
|||
"fabro-petri",
|
||||
"fabro-proc",
|
||||
"fabro-redact",
|
||||
"fabro-sandbox",
|
||||
"fabro-server",
|
||||
"fabro-static",
|
||||
"fabro-store",
|
||||
|
|
@ -2608,38 +2607,6 @@ dependencies = [
|
|||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-sandbox"
|
||||
version = "0.361.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"fabro-github",
|
||||
"fabro-redact",
|
||||
"fabro-static",
|
||||
"fabro-test",
|
||||
"fabro-types",
|
||||
"fabro-util",
|
||||
"futures",
|
||||
"pebble-coding-agent",
|
||||
"reqwest 0.13.4",
|
||||
"sandbox-driver",
|
||||
"sandbox-driver-daytona",
|
||||
"sandbox-driver-docker",
|
||||
"sandbox-driver-docker-config",
|
||||
"sandbox-driver-host",
|
||||
"sandbox-driver-protocol",
|
||||
"sandbox-driver-testing",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.361.0-nightly.0"
|
||||
|
|
@ -2677,7 +2644,6 @@ dependencies = [
|
|||
"fabro-petri",
|
||||
"fabro-proc",
|
||||
"fabro-redact",
|
||||
"fabro-sandbox",
|
||||
"fabro-slack",
|
||||
"fabro-spa",
|
||||
"fabro-static",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ fabro-mcp-server = { path = "../fabro-mcp-server" }
|
|||
fabro-petri = { path = "../../components/fabro-petri" }
|
||||
fabro-manifest = { path = "../../components/fabro-manifest" }
|
||||
fabro-proc = { path = "../../foundation/fabro-proc" }
|
||||
fabro-sandbox = { path = "../../components/fabro-sandbox" }
|
||||
fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox" }
|
||||
sandbox-driver.workspace = true
|
||||
sandbox-driver-host.workspace = true
|
||||
|
|
@ -114,7 +113,6 @@ fabro-db = { path = "../../foundation/fabro-db" }
|
|||
walkdir.workspace = true
|
||||
rmcp = { workspace = true, features = ["client", "transport-child-process"] }
|
||||
fabro-build-support = { path = "../../foundation/build-support" }
|
||||
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] }
|
||||
fabro-server = { path = "../fabro-server", features = ["test-support"] }
|
||||
fabro-petri = { path = "../../components/fabro-petri", features = ["test-support"] }
|
||||
fabro-workflow = { path = "../../components/fabro-workflow", features = ["test-support"] }
|
||||
|
|
|
|||
|
|
@ -180,8 +180,8 @@ fn run_stream_items(run_dir: &Path) -> Vec<RunStreamItem> {
|
|||
/// - `docker-plugin`: the driver's Docker executable over stdio under the
|
||||
/// non-bundled `docker-plugin` kind.
|
||||
///
|
||||
/// The plugin variants need the executables `cargo` builds for
|
||||
/// `fabro-sandbox`; without them (or without a Docker daemon) they skip,
|
||||
/// The plugin variants need the driver's executables on `PATH`; without
|
||||
/// them (or without a Docker daemon) they skip,
|
||||
/// unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set, as CI sets it.
|
||||
macro_rules! sandbox_tests {
|
||||
($name:ident) => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ fabro-interview = { path = "../../components/fabro-interview" }
|
|||
fabro-slack = { path = "../../components/fabro-slack" }
|
||||
fabro-workflow = { path = "../../components/fabro-workflow" }
|
||||
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
|
||||
fabro-sandbox = { path = "../../components/fabro-sandbox" }
|
||||
fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox" }
|
||||
sandbox-driver.workspace = true
|
||||
sandbox-driver-host.workspace = true
|
||||
|
|
@ -127,7 +126,6 @@ tracing-subscriber.workspace = true
|
|||
tokio-util.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
fabro-macros = { path = "../../foundation/fabro-macros" }
|
||||
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] }
|
||||
fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox", features = ["test-support"] }
|
||||
sandbox-driver-testing.workspace = true
|
||||
fabro-store = { path = "../../components/fabro-store", features = ["test-support"] }
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
[package]
|
||||
name = "fabro-sandbox"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Fabro run sandboxes over the sandbox driver: local, Docker, and Daytona"
|
||||
|
||||
[features]
|
||||
default = ["local"]
|
||||
local = []
|
||||
test-support = ["dep:sandbox-driver-testing"]
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
sandbox-driver.workspace = true
|
||||
sandbox-driver-protocol.workspace = true
|
||||
sandbox-driver-host.workspace = true
|
||||
sandbox-driver-docker.workspace = true
|
||||
sandbox-driver-docker-config.workspace = true
|
||||
sandbox-driver-daytona.workspace = true
|
||||
sandbox-driver-testing = { workspace = true, optional = true }
|
||||
pebble-coding-agent.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
reqwest.workspace = true
|
||||
fabro-static.workspace = true
|
||||
fabro-util = { path = "../../foundation/fabro-util" }
|
||||
fabro-redact.workspace = true
|
||||
|
||||
futures = { workspace = true }
|
||||
|
||||
fabro-github = { path = "../fabro-github" }
|
||||
fabro-types = { path = "../../foundation/fabro-types" }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { workspace = true }
|
||||
fabro-github = { path = "../fabro-github", features = ["test-support"] }
|
||||
pebble-coding-agent = { workspace = true, features = ["test-util"] }
|
||||
sandbox-driver-testing.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tempfile = "3"
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
fabro-test.workspace = true
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
use crate::sandbox;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CloneDecision {
|
||||
EmptyWorkspace {
|
||||
reason: EmptyWorkspaceReason,
|
||||
},
|
||||
GitHub {
|
||||
origin_url: String,
|
||||
branch: Option<String>,
|
||||
tag: Option<String>,
|
||||
commit_sha: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct GitHubRepoLayout {
|
||||
pub(crate) primary_repo_path: String,
|
||||
pub(crate) primary_repo_link: String,
|
||||
}
|
||||
|
||||
pub(crate) fn github_repo_layout(
|
||||
origin_url: &str,
|
||||
workspace_root: &str,
|
||||
repos_root: &str,
|
||||
) -> crate::Result<GitHubRepoLayout> {
|
||||
let origin_url = fabro_github::normalize_repo_origin_url(origin_url);
|
||||
let (owner, repo) = fabro_github::parse_github_owner_repo(&origin_url).map_err(|err| {
|
||||
crate::Error::message(format!(
|
||||
"Clone-based sandboxes currently support GitHub repository origins only: {err}"
|
||||
))
|
||||
})?;
|
||||
validate_path_component("owner", &owner)?;
|
||||
validate_path_component("repository", &repo)?;
|
||||
let workspace_root = trim_root(workspace_root);
|
||||
let repos_root = trim_root(repos_root);
|
||||
let repos_owner_path = sandbox::join_sandbox_path(repos_root, &owner);
|
||||
let primary_repo_path = sandbox::join_sandbox_path(&repos_owner_path, &repo);
|
||||
let primary_repo_link = sandbox::join_sandbox_path(workspace_root, &repo);
|
||||
|
||||
Ok(GitHubRepoLayout {
|
||||
primary_repo_path,
|
||||
primary_repo_link,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_path_component(label: &str, component: &str) -> crate::Result<()> {
|
||||
let is_safe = !matches!(component, "." | "..")
|
||||
&& component
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
|
||||
if !is_safe {
|
||||
return Err(crate::Error::message(format!(
|
||||
"GitHub {label} is not a safe repository path component"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The kind of revision a checkout is pinned to instead of the branch's
|
||||
/// current HEAD.
|
||||
///
|
||||
/// The working branch names the checkout the run works on; it never constrains
|
||||
/// which revision is fetched. No layer proves branch/revision ancestry. The
|
||||
/// driver fetches the pin directly and attaches the branch to it, so an
|
||||
/// unavailable revision fails the clone without falling back to branch HEAD,
|
||||
/// and a successful clone has the pin checked out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PinnedRevision {
|
||||
/// An exact commit SHA.
|
||||
Commit,
|
||||
/// A bare tag name; the driver fetches it as `refs/tags/<tag>` so a
|
||||
/// same-named branch is never consulted.
|
||||
Tag,
|
||||
}
|
||||
|
||||
impl PinnedRevision {
|
||||
/// An exact commit is authoritative over a tag; the tag stays on the run
|
||||
/// target as durable identity but does not drive the checkout.
|
||||
pub(crate) fn from_selectors(tag: Option<&str>, commit_sha: Option<&str>) -> Option<Self> {
|
||||
match (commit_sha, tag) {
|
||||
(Some(_), _) => Some(Self::Commit),
|
||||
(None, Some(_)) => Some(Self::Tag),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable prefix for error messages.
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Commit => "Exact commit checkout",
|
||||
Self::Tag => "Tag checkout",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_root(root: &str) -> &str {
|
||||
let trimmed = root.trim_end_matches('/');
|
||||
if trimmed.is_empty() { "/" } else { trimmed }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum EmptyWorkspaceReason {
|
||||
SkipClone,
|
||||
MissingOrigin,
|
||||
}
|
||||
|
||||
impl EmptyWorkspaceReason {
|
||||
pub(crate) fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::SkipClone => "clone disabled; creating an empty workspace",
|
||||
Self::MissingOrigin => {
|
||||
"no clone source was present; creating an empty workspace without repository files"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decide_clone(
|
||||
skip_clone: bool,
|
||||
clone_origin_url: Option<&str>,
|
||||
clone_branch: Option<&str>,
|
||||
clone_tag: Option<&str>,
|
||||
clone_commit_sha: Option<&str>,
|
||||
) -> crate::Result<CloneDecision> {
|
||||
if clone_tag.is_some_and(|tag| tag.trim().is_empty()) {
|
||||
return Err(crate::Error::message(
|
||||
"Tag checkout requires a non-empty tag",
|
||||
));
|
||||
}
|
||||
let tag = clone_tag.map(str::to_string);
|
||||
let commit_sha = clone_commit_sha
|
||||
.map(normalize_exact_commit_sha)
|
||||
.transpose()?;
|
||||
|
||||
if let Some(pin) = PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref()) {
|
||||
let selector = pin.label();
|
||||
if skip_clone {
|
||||
return Err(crate::Error::message(format!(
|
||||
"{selector} requires cloning to be enabled"
|
||||
)));
|
||||
}
|
||||
if clone_origin_url.is_none_or(|url| url.trim().is_empty()) {
|
||||
return Err(crate::Error::message(format!(
|
||||
"{selector} requires a repository origin"
|
||||
)));
|
||||
}
|
||||
// The branch names the checkout the run works on; it is not used to
|
||||
// constrain which commits may be fetched. No layer proves branch/SHA
|
||||
// ancestry, and an unavailable exact commit fails without falling back
|
||||
// to branch HEAD.
|
||||
if clone_branch.is_none_or(|branch| branch.trim().is_empty()) {
|
||||
return Err(crate::Error::message(format!(
|
||||
"{selector} requires a repository branch"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if skip_clone {
|
||||
return Ok(CloneDecision::EmptyWorkspace {
|
||||
reason: EmptyWorkspaceReason::SkipClone,
|
||||
});
|
||||
}
|
||||
|
||||
let Some(origin_url) = clone_origin_url.filter(|url| !url.trim().is_empty()) else {
|
||||
return Ok(CloneDecision::EmptyWorkspace {
|
||||
reason: EmptyWorkspaceReason::MissingOrigin,
|
||||
});
|
||||
};
|
||||
|
||||
let origin_url = fabro_github::normalize_repo_origin_url(origin_url);
|
||||
if let Err(err) = fabro_github::parse_github_owner_repo(&origin_url) {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Clone-based sandboxes currently support GitHub repository origins only: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(CloneDecision::GitHub {
|
||||
origin_url,
|
||||
branch: clone_branch
|
||||
.filter(|branch| !branch.trim().is_empty())
|
||||
.map(str::to_string),
|
||||
tag,
|
||||
commit_sha,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_exact_commit_sha(commit_sha: &str) -> crate::Result<String> {
|
||||
fabro_types::normalize_git_commit_sha(commit_sha).ok_or_else(|| {
|
||||
crate::Error::message("Exact commit SHA must be exactly 40 ASCII hexadecimal characters")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn clean_clone_origin_for_record(clone_origin_url: Option<&str>) -> Option<String> {
|
||||
clone_origin_url
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
.map(fabro_github::normalize_repo_origin_url)
|
||||
}
|
||||
|
||||
pub(crate) fn repo_cloned_for_record(
|
||||
skip_clone: bool,
|
||||
clone_origin_url: Option<&str>,
|
||||
) -> Option<bool> {
|
||||
Some(matches!(
|
||||
decide_clone(skip_clone, clone_origin_url, None, None, None).ok()?,
|
||||
CloneDecision::GitHub { .. }
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skip_clone_overrides_present_origin() {
|
||||
assert_eq!(
|
||||
decide_clone(
|
||||
true,
|
||||
Some("https://gitlab.com/acme/widgets.git"),
|
||||
Some("main"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::EmptyWorkspace {
|
||||
reason: EmptyWorkspaceReason::SkipClone,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_origin_creates_empty_workspace() {
|
||||
assert_eq!(
|
||||
decide_clone(false, None, None, None, None).unwrap(),
|
||||
CloneDecision::EmptyWorkspace {
|
||||
reason: EmptyWorkspaceReason::MissingOrigin,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_origin_is_normalized_with_branch() {
|
||||
assert_eq!(
|
||||
decide_clone(
|
||||
false,
|
||||
Some("git@github.com:acme/widgets.git"),
|
||||
Some("feature/work"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::GitHub {
|
||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||
branch: Some("feature/work".to_string()),
|
||||
tag: None,
|
||||
commit_sha: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_clone_keeps_working_branch_and_bare_tag_distinct() {
|
||||
assert_eq!(
|
||||
decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("release"),
|
||||
Some("v1.2.3"),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::GitHub {
|
||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||
branch: Some("release".to_string()),
|
||||
tag: Some("v1.2.3".to_string()),
|
||||
commit_sha: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_revision_prefers_exact_commit_over_a_tag() {
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
assert_eq!(PinnedRevision::from_selectors(None, None), None);
|
||||
assert_eq!(
|
||||
PinnedRevision::from_selectors(Some("release/v1"), None),
|
||||
Some(PinnedRevision::Tag)
|
||||
);
|
||||
assert_eq!(
|
||||
PinnedRevision::from_selectors(Some("release/v1"), Some(sha)),
|
||||
Some(PinnedRevision::Commit)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_github_origin_fails_without_skip_clone() {
|
||||
let error = decide_clone(
|
||||
false,
|
||||
Some("https://gitlab.com/acme/widgets.git"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("non-GitHub origins should fail");
|
||||
assert!(error.to_string().contains("GitHub repository origins only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_commit_sha_is_validated_and_normalized() {
|
||||
let lowercase = "0123456789abcdef0123456789abcdef01234567";
|
||||
let uppercase = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
|
||||
assert_eq!(
|
||||
decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("moving-branch"),
|
||||
Some("release"),
|
||||
Some(lowercase),
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::GitHub {
|
||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||
branch: Some("moving-branch".to_string()),
|
||||
tag: Some("release".to_string()),
|
||||
commit_sha: Some(lowercase.to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("main"),
|
||||
None,
|
||||
Some(uppercase),
|
||||
)
|
||||
.unwrap(),
|
||||
CloneDecision::GitHub {
|
||||
origin_url: "https://github.com/acme/widgets".to_string(),
|
||||
branch: Some("main".to_string()),
|
||||
tag: None,
|
||||
commit_sha: Some(uppercase.to_ascii_lowercase()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_commit_sha_rejects_noncanonical_inputs() {
|
||||
for sha in [
|
||||
"",
|
||||
"0123456789abcdef0123456789abcdef0123456",
|
||||
"0123456789abcdef0123456789abcdef012345678",
|
||||
"0123456789abcdef0123456789abcdef0123456g",
|
||||
" 0123456789abcdef0123456789abcdef01234567",
|
||||
"0123456789abcdef0123456789abcdef01234567 ",
|
||||
"0123456789abcdef0123456789abcdef012345é",
|
||||
] {
|
||||
let error = decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
None,
|
||||
None,
|
||||
Some(sha),
|
||||
)
|
||||
.expect_err("invalid exact commit SHA should fail");
|
||||
assert!(
|
||||
error.to_string().contains("40 ASCII hexadecimal"),
|
||||
"unexpected error for {sha:?}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_checkout_requires_clone_origin_and_branch() {
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
for (tag, commit_sha) in [(None, Some(sha)), (Some("v1"), None)] {
|
||||
let skip_error = decide_clone(
|
||||
true,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("main"),
|
||||
tag,
|
||||
commit_sha,
|
||||
)
|
||||
.expect_err("pinned checkout with skip-clone should fail");
|
||||
assert!(skip_error.to_string().contains("requires cloning"));
|
||||
|
||||
for origin in [None, Some(""), Some(" ")] {
|
||||
let error = decide_clone(false, origin, Some("main"), tag, commit_sha)
|
||||
.expect_err("pinned checkout without an origin should fail");
|
||||
assert!(error.to_string().contains("requires a repository origin"));
|
||||
}
|
||||
|
||||
for branch in [None, Some(""), Some(" ")] {
|
||||
let error = decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
branch,
|
||||
tag,
|
||||
commit_sha,
|
||||
)
|
||||
.expect_err("pinned checkout without a branch should fail");
|
||||
assert!(error.to_string().contains("requires a repository branch"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_checkout_rejects_empty_tag() {
|
||||
let empty_tag = decide_clone(
|
||||
false,
|
||||
Some("https://github.com/acme/widgets"),
|
||||
Some("main"),
|
||||
Some(""),
|
||||
None,
|
||||
)
|
||||
.expect_err("empty tags should fail");
|
||||
assert!(empty_tag.to_string().contains("non-empty tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_layout_maps_ssh_origin_to_repos_checkout_and_workspace_link() {
|
||||
let layout = github_repo_layout(
|
||||
"git@github.com:brynary/rack-test.git",
|
||||
"/workspace",
|
||||
"/repos",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(layout.primary_repo_path, "/repos/brynary/rack-test");
|
||||
assert_eq!(layout.primary_repo_link, "/workspace/rack-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_layout_normalizes_https_origin_and_trims_roots() {
|
||||
let layout = github_repo_layout(
|
||||
"https://github.com/fabro-sh/fabro.git/",
|
||||
"/workspace/",
|
||||
"/repos/",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(layout.primary_repo_path, "/repos/fabro-sh/fabro");
|
||||
assert_eq!(layout.primary_repo_link, "/workspace/fabro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_layout_rejects_path_traversal_components() {
|
||||
for origin in [
|
||||
"https://github.com/../widgets",
|
||||
"https://github.com/acme/..",
|
||||
"https://github.com/%2e%2e/widgets",
|
||||
] {
|
||||
let error = github_repo_layout(origin, "/workspace", "/repos")
|
||||
.expect_err("unsafe path component should fail");
|
||||
assert!(
|
||||
error.to_string().contains("safe repository path component"),
|
||||
"got {error} for {origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_origin_strips_credentials() {
|
||||
assert_eq!(
|
||||
clean_clone_origin_for_record(Some(
|
||||
"https://x-access-token:secret@github.com/acme/widgets.git"
|
||||
)),
|
||||
Some("https://github.com/acme/widgets".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
//! The `daytona` provider kind: what fabro adds to a run's spec for the
|
||||
//! sandbox-driver Daytona provider.
|
||||
//!
|
||||
//! The environment's options build the spec once; Daytona's overlay fixes
|
||||
//! the working directory, names the run, sets the lifecycle timers, and
|
||||
//! falls back to Daytona's default snapshot when the environment names no
|
||||
//! image or Dockerfile. An image or Dockerfile goes to the driver as is:
|
||||
//! the Daytona provider builds it into a snapshot named by its inputs under
|
||||
//! the API key and reuses that snapshot for the same inputs. The run works
|
||||
//! in `/home/daytona/workspace`, with a cloned repository checked out under
|
||||
//! `/home/daytona/repos` and linked into the workspace.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_types::settings::server::ServerSandboxProviderSettings;
|
||||
use fabro_types::{RunId, SandboxProviderKind};
|
||||
use sandbox_driver::{
|
||||
HealthStatus, Resources, SandboxProvider, SandboxSource, SandboxSpec as DriverSpec, SnapshotId,
|
||||
};
|
||||
use tokio::time;
|
||||
|
||||
pub use crate::driver::DaytonaCredentials;
|
||||
use crate::driver::{ProviderConnectOptions, connect_provider};
|
||||
use crate::driver_sandbox::WorkspaceLayout;
|
||||
|
||||
pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
|
||||
pub(crate) const REPOS_ROOT: &str = "/home/daytona/repos";
|
||||
const DEFAULT_SNAPSHOT: &str = "daytona-medium";
|
||||
pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api";
|
||||
/// Budget for the credential probe `fabro doctor` and the install flow run.
|
||||
pub const DAYTONA_CREDENTIAL_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// Auto-stop applied when `lifecycle.auto_stop` is unset. Omitting the timer
|
||||
/// would inherit Daytona's server-side default of 15 idle minutes, which is
|
||||
/// shorter than a single long inference call and stops the sandbox mid-run;
|
||||
/// 120 minutes clears any realistic call while still reclaiming sandboxes
|
||||
/// leaked by a dead worker. An explicit zero disables auto-stop entirely.
|
||||
const DEFAULT_AUTO_STOP: Duration = Duration::from_hours(2);
|
||||
|
||||
/// Outcome of probing a Daytona credential through the provider's health
|
||||
/// check. The provider owns the list of scopes it needs and the order it
|
||||
/// reports them in; fabro only renders them.
|
||||
#[derive(Debug)]
|
||||
pub struct DaytonaKeyCheck {
|
||||
/// Scopes the key lacks, in Daytona's wire names.
|
||||
pub missing: Vec<String>,
|
||||
/// Every scope the provider requires, for the remediation text.
|
||||
pub required: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("Daytona credential probe timed out after {timeout:?}")]
|
||||
pub struct DaytonaCredentialProbeTimeout {
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl DaytonaCredentialProbeTimeout {
|
||||
#[must_use]
|
||||
pub const fn new(timeout: Duration) -> Self {
|
||||
Self { timeout }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn timeout(&self) -> Duration {
|
||||
self.timeout
|
||||
}
|
||||
}
|
||||
|
||||
impl DaytonaKeyCheck {
|
||||
#[must_use]
|
||||
pub fn ok(&self) -> bool {
|
||||
self.missing.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn missing_display(&self) -> String {
|
||||
self.missing.join(", ")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn missing_message(&self) -> String {
|
||||
format!(
|
||||
"Daytona API key is missing required scopes: {}. Regenerate the key with all \
|
||||
snapshot and sandbox scopes.",
|
||||
self.missing_display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Every scope the provider requires, comma separated, for remediation.
|
||||
#[must_use]
|
||||
pub fn required_display(&self) -> String {
|
||||
self.required.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `credentials` reach Daytona, are accepted, and carry the scopes
|
||||
/// fabro needs. Reachability and authentication failures are errors; a key
|
||||
/// that authenticates but lacks scopes is an `Ok` check that is not `ok()`.
|
||||
pub async fn check_daytona_api_key(
|
||||
credentials: &DaytonaCredentials,
|
||||
probe_timeout: Duration,
|
||||
) -> anyhow::Result<DaytonaKeyCheck> {
|
||||
let probe = async {
|
||||
let provider = connect(credentials).await?;
|
||||
let health = provider
|
||||
.health()
|
||||
.await
|
||||
.map_err(|error| anyhow::Error::new(error).context("Daytona health check failed"))?;
|
||||
match health.status {
|
||||
HealthStatus::Ok | HealthStatus::Unknown => Ok(DaytonaKeyCheck {
|
||||
missing: Vec::new(),
|
||||
required: health.required_permissions,
|
||||
}),
|
||||
HealthStatus::Unauthorized if !health.missing_permissions.is_empty() => {
|
||||
Ok(DaytonaKeyCheck {
|
||||
missing: health.missing_permissions,
|
||||
required: health.required_permissions,
|
||||
})
|
||||
}
|
||||
HealthStatus::Unauthorized => Err(anyhow::anyhow!(
|
||||
"failed to authenticate with Daytona: {}",
|
||||
health
|
||||
.message
|
||||
.unwrap_or_else(|| "the credential was rejected".to_string())
|
||||
)),
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"failed to reach Daytona: {}",
|
||||
health
|
||||
.message
|
||||
.unwrap_or_else(|| "the control plane did not answer".to_string())
|
||||
)),
|
||||
}
|
||||
};
|
||||
match time::timeout(probe_timeout, probe).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(anyhow::Error::new(DaytonaCredentialProbeTimeout::new(
|
||||
probe_timeout,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(credentials: &DaytonaCredentials) -> anyhow::Result<Arc<dyn SandboxProvider>> {
|
||||
connect_provider(
|
||||
&SandboxProviderKind::DAYTONA,
|
||||
&ServerSandboxProviderSettings::default(),
|
||||
&ProviderConnectOptions {
|
||||
host_registry_root: None,
|
||||
daytona: Some(credentials.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|connected| connected.provider)
|
||||
.map_err(|error| anyhow::Error::new(error).context("Failed to connect to Daytona"))
|
||||
}
|
||||
|
||||
/// The workspace layout every Daytona sandbox uses.
|
||||
pub(crate) fn layout() -> WorkspaceLayout {
|
||||
WorkspaceLayout {
|
||||
workspace_root: WORKING_DIRECTORY.to_string(),
|
||||
repos_root: REPOS_ROOT.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Daytona's additions to the environment's spec: the fixed working
|
||||
/// directory, the run's Daytona name, the lifecycle timers, and Daytona's
|
||||
/// default snapshot when the environment names no image or Dockerfile. An
|
||||
/// image or Dockerfile stays as it is: the driver builds it into a cached
|
||||
/// snapshot sized by the spec's resources. A create from the default
|
||||
/// snapshot carries no resources, which Daytona refuses on a sandbox
|
||||
/// created from a snapshot.
|
||||
pub(crate) fn overlay(spec: DriverSpec, run_id: Option<&RunId>) -> DriverSpec {
|
||||
let mut spec = spec.working_directory(WORKING_DIRECTORY);
|
||||
if !matches!(
|
||||
spec.source,
|
||||
SandboxSource::Image { .. } | SandboxSource::Dockerfile { .. }
|
||||
) {
|
||||
spec.source = SandboxSource::Snapshot {
|
||||
id: SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("the default snapshot name is valid"),
|
||||
};
|
||||
spec.resources = Resources::default();
|
||||
}
|
||||
spec.name = run_id.map(|run_id| format!("fabro-{run_id}"));
|
||||
let mut timers = spec.timers;
|
||||
// An explicit zero disables auto-stop; the driver encodes
|
||||
// `Duration::ZERO` as that wire value.
|
||||
timers.auto_stop_after_idle = Some(timers.auto_stop_after_idle.unwrap_or(DEFAULT_AUTO_STOP));
|
||||
// Run sandboxes are never deleted on stop: the run record may need
|
||||
// them again on resume, and `fabro system prune` reclaims them.
|
||||
timers.auto_delete_after_stop = Some(Duration::ZERO);
|
||||
spec.timers(timers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sandbox_driver::{LifecycleTimers, NetworkPolicy};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn run_id() -> RunId {
|
||||
"01HY0000000000000000000000".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_names_the_run_and_carries_fabro_labels_and_timers() {
|
||||
let mut resources = Resources::default();
|
||||
resources.cpu_cores = Some(2);
|
||||
let base = DriverSpec::new(SandboxSource::HostDirectory)
|
||||
.label("team", "platform")
|
||||
.network(NetworkPolicy::CidrAllowList {
|
||||
cidrs: vec!["10.0.0.0/8".to_string()],
|
||||
})
|
||||
.resources(resources);
|
||||
let spec = overlay(base, Some(&run_id()));
|
||||
|
||||
assert!(
|
||||
matches!(&spec.source, SandboxSource::Snapshot { id } if id.as_str() == DEFAULT_SNAPSHOT),
|
||||
"a spec without an image comes from Daytona's default snapshot"
|
||||
);
|
||||
assert_eq!(
|
||||
spec.name.as_deref(),
|
||||
Some("fabro-01HY0000000000000000000000")
|
||||
);
|
||||
assert_eq!(spec.working_directory.as_deref(), Some(WORKING_DIRECTORY));
|
||||
// Fabro's ownership labels are stamped by the scope the provider is
|
||||
// connected through, not by the spec.
|
||||
assert!(!spec.labels.contains_key("sh.fabro.managed"));
|
||||
assert_eq!(
|
||||
spec.labels.get("team").map(String::as_str),
|
||||
Some("platform")
|
||||
);
|
||||
assert!(matches!(
|
||||
&spec.network,
|
||||
NetworkPolicy::CidrAllowList { cidrs } if cidrs == &["10.0.0.0/8".to_string()]
|
||||
));
|
||||
assert_eq!(
|
||||
spec.timers.auto_stop_after_idle,
|
||||
Some(Duration::from_hours(2)),
|
||||
"an unset auto-stop gets fabro's explicit default, never Daytona's 15 minutes"
|
||||
);
|
||||
assert_eq!(spec.timers.auto_delete_after_stop, Some(Duration::ZERO));
|
||||
assert_eq!(
|
||||
spec.resources,
|
||||
Resources::default(),
|
||||
"the default snapshot carries the resources; Daytona refuses them on the sandbox"
|
||||
);
|
||||
assert!(!spec.ephemeral);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_leaves_an_image_and_its_resources_for_the_driver_to_cache() {
|
||||
let mut resources = Resources::default();
|
||||
resources.cpu_cores = Some(2);
|
||||
resources.memory_mb = Some(4096);
|
||||
let base = DriverSpec::new(SandboxSource::Image {
|
||||
reference: "ubuntu:24.04".to_string(),
|
||||
})
|
||||
.resources(resources);
|
||||
let spec = overlay(base, None);
|
||||
assert!(
|
||||
matches!(&spec.source, SandboxSource::Image { reference } if reference == "ubuntu:24.04")
|
||||
);
|
||||
assert_eq!(
|
||||
spec.resources, resources,
|
||||
"the resources size the cached snapshot"
|
||||
);
|
||||
assert_eq!(spec.working_directory.as_deref(), Some(WORKING_DIRECTORY));
|
||||
|
||||
let dockerfile = overlay(
|
||||
DriverSpec::new(SandboxSource::Dockerfile {
|
||||
content: "FROM ubuntu".to_string(),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert!(matches!(
|
||||
dockerfile.source,
|
||||
SandboxSource::Dockerfile { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_passes_explicit_auto_stop_through_and_zero_disables() {
|
||||
let mut timers = LifecycleTimers::default();
|
||||
timers.auto_stop_after_idle = Some(Duration::from_mins(45));
|
||||
let base = DriverSpec::new(SandboxSource::HostDirectory)
|
||||
.network(NetworkPolicy::Block)
|
||||
.timers(timers);
|
||||
let explicit = overlay(base, None);
|
||||
assert_eq!(
|
||||
explicit.timers.auto_stop_after_idle,
|
||||
Some(Duration::from_mins(45))
|
||||
);
|
||||
assert!(matches!(explicit.network, NetworkPolicy::Block));
|
||||
assert!(explicit.name.is_none());
|
||||
|
||||
let mut timers = LifecycleTimers::default();
|
||||
timers.auto_stop_after_idle = Some(Duration::ZERO);
|
||||
let disabled = overlay(
|
||||
DriverSpec::new(SandboxSource::HostDirectory).timers(timers),
|
||||
None,
|
||||
);
|
||||
assert_eq!(disabled.timers.auto_stop_after_idle, Some(Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_scopes_render_as_the_provider_reports_them() {
|
||||
let check = DaytonaKeyCheck {
|
||||
missing: vec!["write:snapshots".to_string(), "write:sandboxes".to_string()],
|
||||
required: vec![
|
||||
"write:snapshots".to_string(),
|
||||
"delete:snapshots".to_string(),
|
||||
"write:sandboxes".to_string(),
|
||||
"delete:sandboxes".to_string(),
|
||||
],
|
||||
};
|
||||
assert!(!check.ok());
|
||||
assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes");
|
||||
assert_eq!(
|
||||
check.missing_message(),
|
||||
"Daytona API key is missing required scopes: write:snapshots, write:sandboxes. \
|
||||
Regenerate the key with all snapshot and sandbox scopes."
|
||||
);
|
||||
assert_eq!(
|
||||
check.required_display(),
|
||||
"write:snapshots, delete:snapshots, write:sandboxes, delete:sandboxes"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_probe_reports_configured_timeout() {
|
||||
// A non-routable address: the probe cannot finish within the budget.
|
||||
let credentials = DaytonaCredentials::new("dtn_test".to_string())
|
||||
.with_api_url(Some("http://10.255.255.1:1/api".to_string()));
|
||||
let err = check_daytona_api_key(&credentials, Duration::from_millis(1))
|
||||
.await
|
||||
.expect_err("probe should time out");
|
||||
let timeout = err
|
||||
.downcast_ref::<DaytonaCredentialProbeTimeout>()
|
||||
.expect("timeout should preserve its type");
|
||||
assert_eq!(timeout.timeout(), Duration::from_millis(1));
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Daytona credential probe timed out after 1ms"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use fabro_types::{RunId, RunSandboxInstance, SandboxDetails};
|
||||
|
||||
use crate::driver::ProviderAccess;
|
||||
use crate::reconnect;
|
||||
|
||||
/// The sandbox identified by `record`, as the run record fabro keeps and
|
||||
/// the status the sandbox driver reports for it, on every provider.
|
||||
pub async fn sandbox_details(
|
||||
record: &RunSandboxInstance,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<RunId>,
|
||||
) -> Result<SandboxDetails> {
|
||||
let sandbox = reconnect::reconnect_for_run(record, access, run_id, None).await?;
|
||||
let status = sandbox.handle()?.describe().await.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to describe {} sandbox '{}': {err}",
|
||||
record.provider,
|
||||
record.runtime.id
|
||||
)
|
||||
})?;
|
||||
Ok(SandboxDetails {
|
||||
sandbox: record.clone(),
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
//! The `docker` provider kind: what fabro adds to a run's spec for the
|
||||
//! sandbox-driver Docker provider.
|
||||
//!
|
||||
//! The environment's options build the spec once; Docker's overlay fixes the
|
||||
//! container's working directory at [`WORKING_DIRECTORY`], supplies the
|
||||
//! default image when the environment names none, and asks the provider to
|
||||
//! pull a missing image. A cloned repository checks out under
|
||||
//! [`REPOS_ROOT`] and is linked into the workspace, so the run works in
|
||||
//! `/workspace/<repo>`.
|
||||
|
||||
use sandbox_driver::{HealthStatus, LifecycleTimers, SandboxSource, SandboxSpec as DriverSpec};
|
||||
use sandbox_driver_docker_config::DockerProviderConfig;
|
||||
|
||||
use crate::driver::ProviderAccess;
|
||||
use crate::driver_sandbox::WorkspaceLayout;
|
||||
use crate::provider_sandbox;
|
||||
|
||||
pub const WORKING_DIRECTORY: &str = "/workspace";
|
||||
pub const REPOS_ROOT: &str = "/repos";
|
||||
/// The image a Docker environment gets when it names none.
|
||||
pub const DEFAULT_IMAGE: &str = "buildpack-deps:noble";
|
||||
|
||||
/// The workspace layout every Docker sandbox uses.
|
||||
pub(crate) fn layout() -> WorkspaceLayout {
|
||||
WorkspaceLayout {
|
||||
workspace_root: WORKING_DIRECTORY.to_string(),
|
||||
repos_root: REPOS_ROOT.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The image a Docker sandbox runs: the environment's, or the default.
|
||||
pub(crate) fn effective_image(spec: &DriverSpec) -> String {
|
||||
match &spec.source {
|
||||
SandboxSource::Image { reference } => reference.clone(),
|
||||
_ => DEFAULT_IMAGE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Docker's additions to the environment's spec: the image it will run,
|
||||
/// the fixed working directory, and a pull for a missing image. Docker has
|
||||
/// no lifecycle timers, so the environment's auto-stop does not apply.
|
||||
pub(crate) fn overlay(spec: DriverSpec) -> DriverSpec {
|
||||
let image = effective_image(&spec);
|
||||
let mut spec = spec;
|
||||
spec.source = SandboxSource::Image { reference: image };
|
||||
spec.timers = LifecycleTimers::default();
|
||||
spec.working_directory(WORKING_DIRECTORY).provider_config(
|
||||
DockerProviderConfig {
|
||||
auto_pull: true,
|
||||
..DockerProviderConfig::default()
|
||||
}
|
||||
.into_value(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the Docker daemon answers. Used by `fabro doctor`.
|
||||
pub async fn check_docker_daemon() -> crate::Result<()> {
|
||||
let provider = provider_sandbox::connect_bundled_docker(&ProviderAccess::default()).await?;
|
||||
let health = provider
|
||||
.health()
|
||||
.await
|
||||
.map_err(|error| crate::Error::context("Docker health check failed", error))?;
|
||||
match health.status {
|
||||
HealthStatus::Ok | HealthStatus::Unknown => Ok(()),
|
||||
HealthStatus::Unreachable | HealthStatus::Unauthorized => {
|
||||
Err(crate::Error::message(health.message.unwrap_or_else(|| {
|
||||
"Failed to reach Docker daemon".to_string()
|
||||
})))
|
||||
}
|
||||
_ => Err(crate::Error::message(
|
||||
"Docker daemon reported an unknown health state",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use sandbox_driver::NetworkPolicy;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overlay_fixes_the_workspace_and_pulls_the_named_image() {
|
||||
let mut requested = LifecycleTimers::default();
|
||||
requested.auto_stop_after_idle = Some(Duration::from_mins(45));
|
||||
let spec = overlay(
|
||||
DriverSpec::new(SandboxSource::Image {
|
||||
reference: "ubuntu:24.04".to_string(),
|
||||
})
|
||||
.network(NetworkPolicy::Block)
|
||||
.timers(requested),
|
||||
);
|
||||
assert!(matches!(
|
||||
&spec.source,
|
||||
SandboxSource::Image { reference } if reference == "ubuntu:24.04"
|
||||
));
|
||||
assert_eq!(spec.working_directory.as_deref(), Some(WORKING_DIRECTORY));
|
||||
assert!(matches!(spec.network, NetworkPolicy::Block));
|
||||
assert_eq!(
|
||||
spec.timers,
|
||||
LifecycleTimers::default(),
|
||||
"docker has no timers to honor the environment's auto-stop with"
|
||||
);
|
||||
let config: DockerProviderConfig =
|
||||
serde_json::from_value(spec.provider_config).expect("docker provider config");
|
||||
assert!(config.auto_pull);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_supplies_the_default_image_when_the_environment_names_none() {
|
||||
let spec = overlay(DriverSpec::new(SandboxSource::HostDirectory));
|
||||
assert!(matches!(
|
||||
&spec.source,
|
||||
SandboxSource::Image { reference } if reference == DEFAULT_IMAGE
|
||||
));
|
||||
assert_eq!(
|
||||
effective_image(&DriverSpec::new(SandboxSource::HostDirectory)),
|
||||
DEFAULT_IMAGE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
//! The one place fabro turns provider configuration into a sandbox-driver
|
||||
//! [`SandboxProvider`].
|
||||
//!
|
||||
//! Bundled kinds (`local`, `docker`, `daytona`) link the driver's provider
|
||||
//! crates in-process. Any other kind launches the configured plugin
|
||||
//! executable over stdio and supervises it. Callers never learn which they
|
||||
//! got: both come back as `Arc<dyn SandboxProvider>` tagged with fabro's own
|
||||
//! [`SandboxProviderKind`], which is what run records and inventory persist.
|
||||
//!
|
||||
//! Credentials arrive explicitly. Nothing here reads the process environment:
|
||||
//! the Daytona key comes from the vault through [`DaytonaCredentials`], and a
|
||||
//! plugin starts from a scrubbed environment containing only what its
|
||||
//! settings declare.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::server::{
|
||||
SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings,
|
||||
};
|
||||
use fabro_types::{BundledProvider, SandboxProviderKind};
|
||||
use sandbox_driver::{ProviderKind, SandboxProvider};
|
||||
use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider};
|
||||
use sandbox_driver_docker::DockerProvider;
|
||||
use sandbox_driver_host::HostProvider;
|
||||
use sandbox_driver_protocol::{PluginConfig, PluginSupervisor};
|
||||
|
||||
/// Binary naming prefix for plugin discovery: a plugin for kind `e2b` is
|
||||
/// `fabro-sandbox-e2b` on `PATH` unless the settings name a path.
|
||||
pub const PLUGIN_BINARY_PREFIX: &str = "fabro-sandbox";
|
||||
|
||||
/// `User-Agent` fabro presents to remote sandbox control planes.
|
||||
pub const USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Explicit Daytona credentials: the SDK's configuration with the API key
|
||||
/// always present and a `Debug` that never prints it. The process
|
||||
/// environment is never consulted.
|
||||
#[derive(Clone)]
|
||||
pub struct DaytonaCredentials(DaytonaConfig);
|
||||
|
||||
impl DaytonaCredentials {
|
||||
/// Credentials for `api_key` against Daytona's public control plane,
|
||||
/// presenting fabro's `User-Agent`.
|
||||
#[must_use]
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self(DaytonaConfig {
|
||||
api_key: Some(api_key),
|
||||
user_agent: Some(USER_AGENT.to_string()),
|
||||
..DaytonaConfig::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Credentials for a vault API key, with the control-plane URL and
|
||||
/// organization taken from `lookup` (server configuration, or the
|
||||
/// process environment in a CLI worker). Nothing is read implicitly.
|
||||
pub fn from_api_key(api_key: String, lookup: impl Fn(&str) -> Option<String>) -> Self {
|
||||
Self::new(api_key)
|
||||
.with_api_url(
|
||||
lookup(EnvVars::DAYTONA_API_URL).or_else(|| lookup(EnvVars::DAYTONA_SERVER_URL)),
|
||||
)
|
||||
.with_organization_id(lookup(EnvVars::DAYTONA_ORGANIZATION_ID))
|
||||
}
|
||||
|
||||
/// The control-plane URL; Daytona's public API when `None`.
|
||||
#[must_use]
|
||||
pub fn with_api_url(mut self, api_url: Option<String>) -> Self {
|
||||
self.0.api_url = api_url;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_organization_id(mut self, organization_id: Option<String>) -> Self {
|
||||
self.0.organization_id = organization_id;
|
||||
self
|
||||
}
|
||||
|
||||
/// A shared HTTP client; tests pass a no-proxy client here.
|
||||
#[must_use]
|
||||
pub fn with_http_client(mut self, http_client: Option<reqwest::Client>) -> Self {
|
||||
self.0.http_client = http_client;
|
||||
self
|
||||
}
|
||||
|
||||
/// The API key, which every constructor sets.
|
||||
#[must_use]
|
||||
pub fn api_key(&self) -> &str {
|
||||
self.0.api_key.as_deref().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The SDK configuration the driver's Daytona provider connects with.
|
||||
#[must_use]
|
||||
pub fn config(&self) -> &DaytonaConfig {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DaytonaCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DaytonaCredentials")
|
||||
.field("api_url", &self.0.api_url)
|
||||
.field("organization_id", &self.0.organization_id)
|
||||
.field("target", &self.0.target)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a process needs to reach every provider a run record can name: the
|
||||
/// server's provider settings (which kinds are enabled, which run as
|
||||
/// plugins) and the Daytona credentials from the vault.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ProviderAccess {
|
||||
pub providers: ServerSandboxProvidersSettings,
|
||||
pub daytona: Option<DaytonaCredentials>,
|
||||
}
|
||||
|
||||
impl ProviderAccess {
|
||||
/// The settings entry for `kind`. A bundled kind without an entry is
|
||||
/// enabled with defaults; any other kind must be configured.
|
||||
pub fn settings_for(
|
||||
&self,
|
||||
kind: &SandboxProviderKind,
|
||||
) -> Option<ServerSandboxProviderSettings> {
|
||||
match self.providers.get(kind) {
|
||||
Some(settings) => Some(settings.clone()),
|
||||
None if kind.bundled().is_some() => Some(ServerSandboxProviderSettings::default()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect_options(&self) -> ProviderConnectOptions {
|
||||
ProviderConnectOptions {
|
||||
host_registry_root: None,
|
||||
daytona: self.daytona.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything besides the settings entry that a provider connection needs.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ProviderConnectOptions {
|
||||
/// Directory where the in-process Host provider records its sandboxes so
|
||||
/// they survive a server restart. `None` uses a fresh temporary registry
|
||||
/// that is removed when the provider drops.
|
||||
pub host_registry_root: Option<PathBuf>,
|
||||
/// Required to connect the bundled Daytona provider.
|
||||
pub daytona: Option<DaytonaCredentials>,
|
||||
}
|
||||
|
||||
/// A provider fabro connected, tagged with the kind fabro persists for it.
|
||||
///
|
||||
/// The driver's own `provider.kind()` may differ from fabro's kind: fabro's
|
||||
/// `local` is the driver's `host`. Persist and dispatch on `kind`, never on
|
||||
/// the driver's name.
|
||||
#[derive(Clone)]
|
||||
pub struct ConnectedProvider {
|
||||
pub kind: SandboxProviderKind,
|
||||
pub provider: Arc<dyn SandboxProvider>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConnectError {
|
||||
#[error("sandbox provider `{kind}` is disabled by server.sandbox.providers.{kind}.enabled")]
|
||||
Disabled { kind: SandboxProviderKind },
|
||||
#[error(
|
||||
"sandbox provider `{kind}` has no plugin settings; add server.sandbox.providers.{kind}"
|
||||
)]
|
||||
MissingPluginSettings { kind: SandboxProviderKind },
|
||||
#[error("sandbox provider `daytona` requires DAYTONA_API_KEY in the vault")]
|
||||
MissingDaytonaCredentials,
|
||||
#[error("sandbox provider `{kind}` is not a valid sandbox-driver kind")]
|
||||
InvalidKind {
|
||||
kind: SandboxProviderKind,
|
||||
#[source]
|
||||
source: sandbox_driver::InvalidIdError,
|
||||
},
|
||||
#[error("failed to connect sandbox provider `{kind}`")]
|
||||
Driver {
|
||||
kind: SandboxProviderKind,
|
||||
#[source]
|
||||
source: sandbox_driver::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Connects the provider behind `kind`.
|
||||
///
|
||||
/// Bundled kinds return the in-process driver provider. Any other kind
|
||||
/// launches the plugin named by `settings.plugin` and returns the driver's
|
||||
/// supervisor, which relaunches the executable after a crash for new work
|
||||
/// only; handles from an earlier generation stay bound to it, and callers
|
||||
/// rebuild them through `attach` with the persisted sandbox id. The
|
||||
/// configured kind is fabro's name for whatever the executable serves; the
|
||||
/// kind the plugin declares is not compared against it. Disabled entries
|
||||
/// are refused here so no caller has to remember the policy check.
|
||||
pub async fn connect_provider(
|
||||
kind: &SandboxProviderKind,
|
||||
settings: &ServerSandboxProviderSettings,
|
||||
options: &ProviderConnectOptions,
|
||||
) -> Result<ConnectedProvider, ConnectError> {
|
||||
if !settings.enabled {
|
||||
return Err(ConnectError::Disabled { kind: kind.clone() });
|
||||
}
|
||||
let driver = |source| ConnectError::Driver {
|
||||
kind: kind.clone(),
|
||||
source,
|
||||
};
|
||||
let provider: Arc<dyn SandboxProvider> = match kind.bundled() {
|
||||
Some(BundledProvider::Local) => match &options.host_registry_root {
|
||||
Some(root) => Arc::new(HostProvider::with_registry(root).await.map_err(driver)?),
|
||||
None => Arc::new(HostProvider::new()),
|
||||
},
|
||||
Some(BundledProvider::Docker) => {
|
||||
// The daemon is not required to answer at connect time; `health`
|
||||
// reports an unreachable daemon so preflight sees the cause.
|
||||
Arc::new(DockerProvider::connect_unverified().map_err(driver)?)
|
||||
}
|
||||
Some(BundledProvider::Daytona) => {
|
||||
let credentials = options
|
||||
.daytona
|
||||
.as_ref()
|
||||
.ok_or(ConnectError::MissingDaytonaCredentials)?;
|
||||
Arc::new(
|
||||
DaytonaProvider::connect_explicit(credentials.config().clone())
|
||||
.await
|
||||
.map_err(driver)?,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let plugin = settings
|
||||
.plugin
|
||||
.as_ref()
|
||||
.ok_or_else(|| ConnectError::MissingPluginSettings { kind: kind.clone() })?;
|
||||
let driver_kind = ProviderKind::try_new(kind.as_str()).map_err(|source| {
|
||||
ConnectError::InvalidKind {
|
||||
kind: kind.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
// The supervisor is the provider: it launches the executable now,
|
||||
// so a misconfigured plugin fails at connect time, and relaunches
|
||||
// it after a crash for new work only.
|
||||
Arc::new(
|
||||
PluginSupervisor::launch(PLUGIN_BINARY_PREFIX, plugin_config(driver_kind, plugin))
|
||||
.await
|
||||
.map_err(driver)?,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(ConnectedProvider {
|
||||
kind: kind.clone(),
|
||||
provider,
|
||||
})
|
||||
}
|
||||
|
||||
fn plugin_config(kind: ProviderKind, settings: &SandboxPluginSettings) -> PluginConfig {
|
||||
PluginConfig {
|
||||
kind,
|
||||
path: settings.path.as_deref().map(PathBuf::from),
|
||||
sha256: settings.sha256.clone(),
|
||||
dev: settings.dev,
|
||||
args: settings.args.clone(),
|
||||
env: settings
|
||||
.env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
inherit_env: settings.inherit_env.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings(plugin: Option<SandboxPluginSettings>) -> ServerSandboxProviderSettings {
|
||||
ServerSandboxProviderSettings {
|
||||
enabled: true,
|
||||
plugin,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_entries_are_refused_before_any_connection() {
|
||||
let error = connect_provider(
|
||||
&SandboxProviderKind::DOCKER,
|
||||
&ServerSandboxProviderSettings {
|
||||
enabled: false,
|
||||
plugin: None,
|
||||
},
|
||||
&ProviderConnectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("disabled provider must not connect");
|
||||
assert!(
|
||||
matches!(error, ConnectError::Disabled { kind } if kind == SandboxProviderKind::DOCKER)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daytona_requires_explicit_credentials() {
|
||||
let error = connect_provider(
|
||||
&SandboxProviderKind::DAYTONA,
|
||||
&settings(None),
|
||||
&ProviderConnectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("daytona must not fall back to the environment");
|
||||
assert!(matches!(error, ConnectError::MissingDaytonaCredentials));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_kinds_require_plugin_settings() {
|
||||
let kind = SandboxProviderKind::try_new("e2b").unwrap();
|
||||
let error = connect_provider(&kind, &settings(None), &ProviderConnectOptions::default())
|
||||
.await
|
||||
.err()
|
||||
.expect("a plugin kind without settings cannot launch");
|
||||
assert!(matches!(error, ConnectError::MissingPluginSettings { kind: k } if k == kind));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_connects_the_host_provider_in_process() {
|
||||
let registry = tempfile::tempdir().unwrap();
|
||||
let connected = connect_provider(
|
||||
&SandboxProviderKind::LOCAL,
|
||||
&settings(None),
|
||||
&ProviderConnectOptions {
|
||||
host_registry_root: Some(registry.path().to_path_buf()),
|
||||
daytona: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("host provider connects without external services");
|
||||
assert_eq!(connected.kind, SandboxProviderKind::LOCAL);
|
||||
assert_eq!(connected.provider.kind().as_str(), "host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_config_carries_every_launch_setting() {
|
||||
let config = plugin_config(
|
||||
ProviderKind::try_new("e2b").unwrap(),
|
||||
&SandboxPluginSettings {
|
||||
path: Some("/opt/e2b".to_string()),
|
||||
sha256: Some("abc".to_string()),
|
||||
dev: true,
|
||||
args: vec!["--flag".to_string()],
|
||||
env: BTreeMap::from([("A".to_string(), "1".to_string())]),
|
||||
inherit_env: vec!["PATH".to_string()],
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
config.path.as_deref(),
|
||||
Some(std::path::Path::new("/opt/e2b"))
|
||||
);
|
||||
assert_eq!(config.sha256.as_deref(), Some("abc"));
|
||||
assert!(config.dev);
|
||||
assert_eq!(config.args, vec!["--flag"]);
|
||||
assert_eq!(config.env.get("A").map(String::as_str), Some("1"));
|
||||
assert_eq!(config.inherit_env, vec!["PATH"]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,316 +0,0 @@
|
|||
//! What an environment asks of a sandbox, mapped once onto the driver's spec.
|
||||
//!
|
||||
//! The environment names an image or Dockerfile, resources, a network
|
||||
//! policy, labels, variables, and a lifecycle. Every provider starts from
|
||||
//! the same driver [`SandboxSpec`] built here; a bundled provider adds only
|
||||
//! what its backend needs on top (the Docker working directory and default
|
||||
//! image, the Daytona snapshot and timers) in its own overlay, and the
|
||||
//! ownership scope adds fabro's labels. The clone request travels beside
|
||||
//! the spec as a [`CloneRequest`]: fabro validates and records it, and
|
||||
//! refuses one that asks for a clone.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_types::RunId;
|
||||
use fabro_types::settings::run::{
|
||||
DockerfileSource, EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings,
|
||||
};
|
||||
use sandbox_driver::{
|
||||
Capabilities, LifecycleTimers, NetworkPolicy, Resources, SandboxSource, SandboxSpec,
|
||||
};
|
||||
|
||||
/// The repository a provider sandbox is named for, if any. Fabro validates
|
||||
/// and records the request; it no longer clones, so a request that asks
|
||||
/// for a clone is refused when the sandbox is planned.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CloneRequest {
|
||||
pub origin_url: Option<String>,
|
||||
/// The branch the checkout works on.
|
||||
pub branch: Option<String>,
|
||||
/// A tag to pin the checkout to; the branch still names the checkout.
|
||||
pub tag: Option<String>,
|
||||
/// An exact commit to pin the checkout to, authoritative over `tag`.
|
||||
pub commit_sha: Option<String>,
|
||||
/// Maximum Git history depth fetched; `None` fetches full history.
|
||||
pub depth: Option<u32>,
|
||||
/// Create an empty workspace instead of cloning, even when an origin
|
||||
/// is present.
|
||||
pub skip: bool,
|
||||
}
|
||||
|
||||
impl CloneRequest {
|
||||
/// No clone: the run starts in an empty workspace.
|
||||
#[must_use]
|
||||
pub fn none() -> Self {
|
||||
Self {
|
||||
skip: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment's clone policy: whether to clone and how deep. The
|
||||
/// origin and the selectors come from the run's target.
|
||||
#[must_use]
|
||||
pub fn from_settings(clone: &RunCloneSettings) -> Self {
|
||||
Self {
|
||||
depth: clone
|
||||
.depth_limit()
|
||||
.and_then(|depth| u32::try_from(depth).ok()),
|
||||
skip: !clone.enabled,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver spec every provider starts from: the environment's source
|
||||
/// (an image, a Dockerfile, or a managed directory when it names neither),
|
||||
/// its labels, variables, resources, network policy, and auto-stop. `env`
|
||||
/// is the environment's variables, resolved by the caller: the worker
|
||||
/// resolves secrets through the vault, while preflight carries them in
|
||||
/// source form.
|
||||
///
|
||||
/// A Dockerfile given as a path must have been resolved to inline content
|
||||
/// earlier; none of the providers can read a path.
|
||||
pub fn sandbox_spec_for_environment(
|
||||
settings: &RunEnvironmentSettings,
|
||||
env: BTreeMap<String, String>,
|
||||
) -> crate::Result<SandboxSpec> {
|
||||
// fabro-config rejects environments that set both image.docker and
|
||||
// image.dockerfile. If both still arrive here, the image wins.
|
||||
let source = match (&settings.image.docker, &settings.image.dockerfile) {
|
||||
(Some(reference), _) => SandboxSource::Image {
|
||||
reference: reference.clone(),
|
||||
},
|
||||
(None, Some(DockerfileSource::Inline(content))) => SandboxSource::Dockerfile {
|
||||
content: content.clone(),
|
||||
},
|
||||
(None, Some(DockerfileSource::Path { path })) => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"environment `{}` names a Dockerfile path ({path}) that should have been \
|
||||
resolved to inline content before sandbox creation",
|
||||
settings.id
|
||||
)));
|
||||
}
|
||||
// A provider without images (a host-style plugin) manages a
|
||||
// workspace directory of its own.
|
||||
(None, None) => SandboxSource::HostDirectory,
|
||||
};
|
||||
let network = match settings.network.mode {
|
||||
EnvironmentNetworkMode::Block => NetworkPolicy::Block,
|
||||
EnvironmentNetworkMode::AllowAll => NetworkPolicy::AllowAll,
|
||||
EnvironmentNetworkMode::CidrAllowList => NetworkPolicy::CidrAllowList {
|
||||
cidrs: settings.network.allow.clone(),
|
||||
},
|
||||
};
|
||||
let mut spec = SandboxSpec::new(source).network(network);
|
||||
// The environment's labels; fabro's ownership labels are stamped by the
|
||||
// ownership scope the provider is connected through.
|
||||
for (key, value) in &settings.labels {
|
||||
spec = spec.label(key, value);
|
||||
}
|
||||
for (key, value) in env {
|
||||
spec = spec.env_var(key, value);
|
||||
}
|
||||
let mut resources = Resources::default();
|
||||
resources.cpu_cores = settings
|
||||
.resources
|
||||
.cpu
|
||||
.and_then(|cpu| u32::try_from(cpu).ok());
|
||||
resources.memory_mb = settings
|
||||
.resources
|
||||
.memory
|
||||
.map(|size| mebibytes(size.as_bytes()));
|
||||
resources.disk_mb = settings
|
||||
.resources
|
||||
.disk
|
||||
.map(|size| mebibytes(size.as_bytes()));
|
||||
let mut timers = LifecycleTimers::default();
|
||||
timers.auto_stop_after_idle = settings
|
||||
.lifecycle
|
||||
.auto_stop
|
||||
.map(|duration| duration.as_std());
|
||||
Ok(spec.resources(resources).timers(timers))
|
||||
}
|
||||
|
||||
/// Whole mebibytes, rounded up: the unit the driver sizes resources in.
|
||||
fn mebibytes(bytes: u64) -> u64 {
|
||||
bytes.div_ceil(1024 * 1024)
|
||||
}
|
||||
|
||||
/// The provider-side name of a run's sandbox.
|
||||
pub(crate) fn run_name(run_id: &RunId) -> String {
|
||||
format!("fabro-run-{run_id}")
|
||||
}
|
||||
|
||||
/// The environment's default `allow_all` means "unrestricted", which a
|
||||
/// provider without network controls already is; asking such a provider
|
||||
/// for it explicitly would be rejected. An explicit restriction is still
|
||||
/// requested, and refused by the provider when it cannot honor it.
|
||||
pub(crate) fn supported_network(
|
||||
requested: NetworkPolicy,
|
||||
capabilities: &Capabilities,
|
||||
) -> NetworkPolicy {
|
||||
match requested {
|
||||
NetworkPolicy::AllowAll if !capabilities.network.allow_all => {
|
||||
NetworkPolicy::ProviderDefault
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment's auto-stop is a request a backend without timers
|
||||
/// cannot take; such a provider gets no timers rather than a rejected spec.
|
||||
pub(crate) fn supported_timers(
|
||||
requested: LifecycleTimers,
|
||||
capabilities: &Capabilities,
|
||||
) -> LifecycleTimers {
|
||||
if capabilities.lifecycle.timers {
|
||||
requested
|
||||
} else {
|
||||
LifecycleTimers::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_types::settings::run::{
|
||||
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings,
|
||||
EnvironmentResourcesSettings,
|
||||
};
|
||||
use fabro_types::settings::{Duration as SettingsDuration, Size};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn environment(kind: &str) -> RunEnvironmentSettings {
|
||||
RunEnvironmentSettings {
|
||||
id: kind.to_string(),
|
||||
provider: SandboxProviderKind::try_new(kind).unwrap(),
|
||||
cwd: None,
|
||||
image: EnvironmentImageSettings::default(),
|
||||
resources: EnvironmentResourcesSettings::default(),
|
||||
network: EnvironmentNetworkSettings::default(),
|
||||
lifecycle: EnvironmentLifecycleSettings::default(),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
env: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_environment_without_an_image_asks_for_a_managed_directory() {
|
||||
let spec = sandbox_spec_for_environment(
|
||||
&environment("host"),
|
||||
BTreeMap::from([("FOO".to_string(), "bar".to_string())]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(spec.source, SandboxSource::HostDirectory));
|
||||
assert!(spec.working_directory.is_none());
|
||||
assert!(
|
||||
spec.name.is_none(),
|
||||
"the run names the sandbox, not the environment"
|
||||
);
|
||||
assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar"));
|
||||
assert_eq!(
|
||||
spec.labels.get("team").map(String::as_str),
|
||||
Some("platform")
|
||||
);
|
||||
assert!(
|
||||
!spec.labels.contains_key("sh.fabro.managed"),
|
||||
"ownership labels come from the scope, not the environment"
|
||||
);
|
||||
assert!(matches!(spec.network, NetworkPolicy::AllowAll));
|
||||
assert_eq!(spec.resources, Resources::default());
|
||||
assert_eq!(spec.timers, LifecycleTimers::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_environment_with_an_image_maps_resources_network_and_lifecycle() {
|
||||
let mut settings = environment("e2b");
|
||||
settings.image.docker = Some("ubuntu:24.04".to_string());
|
||||
settings.resources.cpu = Some(2);
|
||||
settings.resources.memory = Some(Size::from_bytes(4_000_000_000));
|
||||
settings.network.mode = EnvironmentNetworkMode::Block;
|
||||
settings.lifecycle.auto_stop = Some(SettingsDuration::from_std(Duration::from_mins(45)));
|
||||
|
||||
let spec = sandbox_spec_for_environment(&settings, BTreeMap::new()).unwrap();
|
||||
assert!(matches!(
|
||||
&spec.source,
|
||||
SandboxSource::Image { reference } if reference == "ubuntu:24.04"
|
||||
));
|
||||
assert_eq!(spec.resources.cpu_cores, Some(2));
|
||||
assert_eq!(spec.resources.memory_mb, Some(3815));
|
||||
assert!(matches!(spec.network, NetworkPolicy::Block));
|
||||
assert_eq!(
|
||||
spec.timers.auto_stop_after_idle,
|
||||
Some(Duration::from_mins(45))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clone_request_carries_the_environments_policy() {
|
||||
let clone = CloneRequest::from_settings(&RunCloneSettings::default());
|
||||
assert_eq!(clone.depth, Some(100));
|
||||
assert!(!clone.skip);
|
||||
|
||||
let clone = CloneRequest::from_settings(&RunCloneSettings {
|
||||
enabled: false,
|
||||
depth: 0,
|
||||
});
|
||||
assert_eq!(clone.depth, None);
|
||||
assert!(clone.skip);
|
||||
assert!(CloneRequest::none().skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_dockerfile_becomes_the_source_and_a_path_is_rejected() {
|
||||
let mut settings = environment("daytona");
|
||||
settings.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu".to_string()));
|
||||
let spec = sandbox_spec_for_environment(&settings, BTreeMap::new()).unwrap();
|
||||
assert!(matches!(
|
||||
spec.source,
|
||||
SandboxSource::Dockerfile { content } if content == "FROM ubuntu"
|
||||
));
|
||||
|
||||
settings.image.dockerfile = Some(DockerfileSource::Path {
|
||||
path: "Dockerfile".to_string(),
|
||||
});
|
||||
let error = sandbox_spec_for_environment(&settings, BTreeMap::new()).unwrap_err();
|
||||
assert!(error.to_string().contains("Dockerfile path"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_all_falls_back_to_the_provider_default_without_network_control() {
|
||||
let none = Capabilities::minimal(sandbox_driver::Isolation::None);
|
||||
assert!(matches!(
|
||||
supported_network(NetworkPolicy::AllowAll, &none),
|
||||
NetworkPolicy::ProviderDefault
|
||||
));
|
||||
assert!(matches!(
|
||||
supported_network(NetworkPolicy::Block, &none),
|
||||
NetworkPolicy::Block
|
||||
));
|
||||
let mut full = Capabilities::minimal(sandbox_driver::Isolation::Container);
|
||||
full.network.allow_all = true;
|
||||
assert!(matches!(
|
||||
supported_network(NetworkPolicy::AllowAll, &full),
|
||||
NetworkPolicy::AllowAll
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timers_are_dropped_for_a_provider_without_them() {
|
||||
let mut requested = LifecycleTimers::default();
|
||||
requested.auto_stop_after_idle = Some(Duration::from_mins(45));
|
||||
let none = Capabilities::minimal(sandbox_driver::Isolation::None);
|
||||
assert_eq!(
|
||||
supported_timers(requested, &none),
|
||||
LifecycleTimers::default()
|
||||
);
|
||||
let mut with_timers = Capabilities::minimal(sandbox_driver::Isolation::Container);
|
||||
with_timers.lifecycle.timers = true;
|
||||
assert_eq!(supported_timers(requested, &with_timers), requested);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
use std::fmt::Write as _;
|
||||
|
||||
use fabro_util::error::{collect_causes, render_with_causes};
|
||||
|
||||
use crate::sandbox::{DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redacted_output_tail};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
|
||||
#[error("{message}")]
|
||||
Context {
|
||||
message: String,
|
||||
#[source]
|
||||
source: Box<dyn std::error::Error + Send + Sync + 'static>,
|
||||
},
|
||||
|
||||
#[error("{message}")]
|
||||
AnyhowContext {
|
||||
message: String,
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
|
||||
/// 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 `Exec`, `Git`, and `NotFound`
|
||||
/// without string matching.
|
||||
#[error(transparent)]
|
||||
Driver(Box<sandbox_driver::Error>),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn message(message: impl Into<String>) -> Self {
|
||||
Self::Message(message.into())
|
||||
}
|
||||
|
||||
pub fn context(
|
||||
message: impl Into<String>,
|
||||
source: impl std::error::Error + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self::Context {
|
||||
message: message.into(),
|
||||
source: Box::new(source),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn context_anyhow(message: impl Into<String>, source: anyhow::Error) -> Self {
|
||||
Self::AnyhowContext {
|
||||
message: message.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_redacted_output_tail(&self) -> Option<fabro_types::ExecOutputTail> {
|
||||
default_redacted_output_tail(self)
|
||||
}
|
||||
|
||||
pub fn causes(&self) -> Vec<String> {
|
||||
collect_causes(self)
|
||||
}
|
||||
|
||||
/// The underlying sandbox-driver error, when this error carries one
|
||||
/// anywhere in its chain.
|
||||
pub fn driver(&self) -> Option<&sandbox_driver::Error> {
|
||||
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self);
|
||||
while let Some(err) = current {
|
||||
if let Some(Self::Driver(driver)) = err.downcast_ref::<Self>() {
|
||||
return Some(driver.as_ref());
|
||||
}
|
||||
if let Some(driver) = err.downcast_ref::<sandbox_driver::Error>() {
|
||||
return Some(driver);
|
||||
}
|
||||
current = err.source();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn display_with_causes(&self) -> String {
|
||||
render_with_causes(&self.to_string(), &self.causes())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sandbox_driver::Error> for Error {
|
||||
fn from(value: sandbox_driver::Error) -> Self {
|
||||
Self::Driver(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
pub fn default_redacted_output_tail(
|
||||
err: &(dyn std::error::Error + 'static),
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
let mut current = Some(err);
|
||||
while let Some(err) = current {
|
||||
if let Some(Error::Driver(driver)) = err.downcast_ref::<Error>() {
|
||||
if let Some(tail) = driver_output_tail(driver) {
|
||||
return Some(tail);
|
||||
}
|
||||
}
|
||||
if let Some(driver) = err.downcast_ref::<sandbox_driver::Error>() {
|
||||
if let Some(tail) = driver_output_tail(driver) {
|
||||
return Some(tail);
|
||||
}
|
||||
}
|
||||
current = err.source();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The output a driver failure carries: a command that ran and failed, or
|
||||
/// a git operation whose command output the driver kept as evidence.
|
||||
fn driver_output_tail(error: &sandbox_driver::Error) -> Option<fabro_types::ExecOutputTail> {
|
||||
let failure = match error {
|
||||
sandbox_driver::Error::Exec(failure) => failure,
|
||||
sandbox_driver::Error::Git(git) => git.output()?,
|
||||
_ => return None,
|
||||
};
|
||||
redacted_output_tail(
|
||||
&String::from_utf8_lossy(failure.stdout()),
|
||||
&String::from_utf8_lossy(failure.stderr()),
|
||||
DEFAULT_EXEC_OUTPUT_TAIL_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn display_for_log(err: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut rendered = render_with_causes(&err.to_string(), &collect_causes(err));
|
||||
if let Some(tail) = default_redacted_output_tail(err) {
|
||||
append_tail_for_log(
|
||||
&mut rendered,
|
||||
"stderr",
|
||||
tail.stderr.as_deref(),
|
||||
tail.stderr_truncated,
|
||||
);
|
||||
append_tail_for_log(
|
||||
&mut rendered,
|
||||
"stdout",
|
||||
tail.stdout.as_deref(),
|
||||
tail.stdout_truncated,
|
||||
);
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn append_tail_for_log(rendered: &mut String, stream: &str, tail: Option<&str>, truncated: bool) {
|
||||
let tail = tail.unwrap_or("");
|
||||
let _ = write!(
|
||||
rendered,
|
||||
"\n--- {stream} (truncated={truncated}, bytes={}) ---\n{tail}",
|
||||
tail.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use sandbox_driver::{ExecResult, Termination};
|
||||
|
||||
use super::*;
|
||||
use crate::exec::ExecResultExt;
|
||||
|
||||
const SECRET: &str = "ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
|
||||
|
||||
fn failed_push(stdout: &str, stderr: &str) -> Error {
|
||||
let mut result =
|
||||
ExecResult::new(Termination::Exited, Some(128), Duration::from_millis(210));
|
||||
result.stdout = stdout.as_bytes().to_vec();
|
||||
result.stderr = stderr.as_bytes().to_vec();
|
||||
result.into_exec_error("git push origin refs/heads/run")
|
||||
}
|
||||
|
||||
fn leaky_stderr() -> String {
|
||||
format!(
|
||||
"fatal: unable to access 'https://x-access-token:{SECRET}@github.com/owner/repo/':\n\
|
||||
remote: Permission to owner/repo.git denied\n\
|
||||
identity ~/.ssh/id_rsa_work"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_display_is_log_safe() {
|
||||
let error = failed_push("", &leaky_stderr());
|
||||
let rendered = error.to_string();
|
||||
|
||||
assert_exec_rendering_is_safe(&rendered);
|
||||
assert!(rendered.contains("git push origin refs/heads/run"));
|
||||
assert!(rendered.contains("128"));
|
||||
assert!(rendered.contains("210 ms"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_with_causes_does_not_reintroduce_raw_exec_output() {
|
||||
let exec_error = failed_push(&format!("stdout secret {SECRET}"), &leaky_stderr());
|
||||
let error = Error::context("metadata push failed", exec_error);
|
||||
let rendered = error.display_with_causes();
|
||||
|
||||
assert_exec_rendering_is_safe(&rendered);
|
||||
assert!(rendered.contains("metadata push failed"));
|
||||
assert!(rendered.contains("git push origin refs/heads/run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_driver_error_is_reachable_through_the_context_chain() {
|
||||
let error = Error::context("metadata push failed", failed_push("", "boom"));
|
||||
|
||||
let Some(sandbox_driver::Error::Exec(failure)) = error.driver() else {
|
||||
panic!("expected an exec failure, got {error:?}");
|
||||
};
|
||||
assert_eq!(failure.label(), "git push origin refs/heads/run");
|
||||
assert_eq!(failure.exit_code(), Some(128));
|
||||
assert_eq!(failure.termination(), Termination::Exited);
|
||||
assert!(Error::message("plain").driver().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_for_log_walks_context_chain_and_emits_tail() {
|
||||
let exec_error = failed_push("last stdout line", "last stderr line");
|
||||
let error = Error::context("metadata push failed", exec_error);
|
||||
|
||||
let rendered = display_for_log(&error);
|
||||
|
||||
assert!(rendered.contains("metadata push failed"));
|
||||
assert!(rendered.contains("git push origin refs/heads/run"));
|
||||
assert!(rendered.contains("--- stderr (truncated=false, bytes=16) ---"));
|
||||
assert!(rendered.contains("last stderr line"));
|
||||
assert!(rendered.contains("--- stdout (truncated=false, bytes=16) ---"));
|
||||
assert!(rendered.contains("last stdout line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_for_log_redacts_secrets() {
|
||||
let error = failed_push(
|
||||
&format!("stdout secret {SECRET}"),
|
||||
&format!("stderr secret {SECRET}"),
|
||||
);
|
||||
|
||||
let rendered = display_for_log(&error);
|
||||
|
||||
assert!(
|
||||
!rendered.contains(SECRET),
|
||||
"log rendering leaked raw secret: {rendered}"
|
||||
);
|
||||
assert!(rendered.contains("REDACTED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_for_log_for_non_exec_error_returns_chain_only() {
|
||||
let error = Error::context("outer failure", std::io::Error::other("leaf failure"));
|
||||
|
||||
let rendered = display_for_log(&error);
|
||||
|
||||
assert_eq!(rendered, "outer failure\n caused by: leaf failure");
|
||||
assert!(!rendered.contains("--- stderr"));
|
||||
assert!(!rendered.contains("--- stdout"));
|
||||
}
|
||||
|
||||
fn assert_exec_rendering_is_safe(rendered: &str) {
|
||||
for forbidden in [
|
||||
"fatal:",
|
||||
"remote:",
|
||||
"x-access-token",
|
||||
SECRET,
|
||||
"~/.ssh",
|
||||
"id_rsa_work",
|
||||
] {
|
||||
assert!(
|
||||
!rendered.contains(forbidden),
|
||||
"Display leaked {forbidden:?}: {rendered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_error_exposes_default_redacted_output_tail() {
|
||||
let error = failed_push("last stdout line", &format!("stderr secret {SECRET}"));
|
||||
|
||||
let tail = error.default_redacted_output_tail().expect("tail present");
|
||||
assert_eq!(tail.stdout.as_deref(), Some("last stdout line"));
|
||||
assert!(
|
||||
tail.stderr
|
||||
.as_deref()
|
||||
.expect("stderr tail")
|
||||
.contains("REDACTED")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_tail_helper_walks_context_chain() {
|
||||
let exec_error = failed_push("last stdout line", "last stderr line");
|
||||
let error = Error::context("metadata push failed", exec_error);
|
||||
|
||||
let tail = default_redacted_output_tail(&error).expect("tail present");
|
||||
|
||||
assert_eq!(tail.stdout.as_deref(), Some("last stdout line"));
|
||||
assert_eq!(tail.stderr.as_deref(), Some("last stderr line"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,738 +0,0 @@
|
|||
//! Fabro's command execution policy over the sandbox-driver [`Exec`] facet.
|
||||
//!
|
||||
//! The vocabulary is the driver's own: an [`ExecSpec`] and [`ExecControls`]
|
||||
//! go in, an [`ExecResult`] or [`ExecStreamingResult`] comes out. This
|
||||
//! module adds fabro's policy on the way in and fabro's reading of a result
|
||||
//! on the way out.
|
||||
//!
|
||||
//! A command runs as Bash source under `bash -c` with `BASH_ENV` blanked by
|
||||
//! the driver whatever the caller passed, and ends in one of three ways:
|
||||
//!
|
||||
//! - **timeout**: the spec's timeout fires and the provider runs the stop
|
||||
//! ladder fabro asks for — `TERM`, then `KILL` after
|
||||
//! [`SandboxExec::stop_grace`]. The result reports [`Termination::TimedOut`].
|
||||
//! - **cancellation**: the caller's [`CancellationToken`] is the `term` stop;
|
||||
//! the provider escalates to `KILL` after the same grace. The result reports
|
||||
//! [`Termination::Cancelled`].
|
||||
//! - **exit**: the process ended on its own.
|
||||
//!
|
||||
//! Output is drained regardless of the retention cap and delivered live
|
||||
//! through the caller's [`sandbox_driver::OutputSink`]. Fabro reads command
|
||||
//! output as text, so the policy asks the driver for
|
||||
//! [`OutputSanitization::StripAll`]: terminal escape sequences and stray
|
||||
//! control characters never reach a result, a sink chunk, or a tail. Secret
|
||||
//! redaction stays fabro's job and happens only when a tail is rendered for
|
||||
//! events or logs ([`ExecResultExt`]). The explicit environment reaches the
|
||||
//! provider as the caller composed it: the driver filters credential-shaped
|
||||
//! names out of the *inherited* host environment itself and treats the
|
||||
//! spec's own variables as the deliberate channel for secrets, so fabro adds
|
||||
//! no filter of its own.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_types::{CommandTermination, ExecOutputTail};
|
||||
use sandbox_driver::{
|
||||
Exec, ExecControls, ExecFailure, ExecResult, ExecSpec, ExecStreamingResult, OutputSanitization,
|
||||
SpawnSpec, StdioProcess, Termination,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::sandbox::{DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redacted_output_tail};
|
||||
|
||||
/// Time between `TERM` and `KILL` when fabro stops a command.
|
||||
pub const DEFAULT_STOP_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Retention when a caller sets no cap: enough for any build log fabro
|
||||
/// renders, bounded so a runaway command cannot exhaust memory.
|
||||
pub const DEFAULT_RETAINED_OUTPUT_BYTES: usize = sandbox_driver::DEFAULT_BUFFER_BYTES;
|
||||
|
||||
/// Fabro's exec policy bound to one driver [`Exec`] facet.
|
||||
pub struct SandboxExec<'a> {
|
||||
exec: &'a dyn Exec,
|
||||
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> {
|
||||
#[must_use]
|
||||
pub fn new(exec: &'a dyn Exec) -> Self {
|
||||
Self {
|
||||
exec,
|
||||
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; the
|
||||
/// provider runs the ladder.
|
||||
#[must_use]
|
||||
pub fn with_stop_grace(mut self, stop_grace: Duration) -> Self {
|
||||
self.stop_grace = stop_grace;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn stop_grace(&self) -> Duration {
|
||||
self.stop_grace
|
||||
}
|
||||
|
||||
/// Runs Bash source to completion and returns its captured output.
|
||||
///
|
||||
/// Equivalent to `bash -c <command>` with a clean, non-login shell: no
|
||||
/// `errexit`, no `pipefail`, `BASH_ENV` blanked. A caller that wants
|
||||
/// different semantics writes them into the command. `None` for
|
||||
/// `timeout` runs without a deadline.
|
||||
pub async fn run(
|
||||
&self,
|
||||
command: &str,
|
||||
timeout: Option<Duration>,
|
||||
working_dir: Option<&str>,
|
||||
env_vars: Option<&HashMap<String, String>>,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> crate::Result<ExecResult> {
|
||||
let mut spec = ExecSpec::bash(command).no_timeout();
|
||||
if let Some(timeout) = timeout {
|
||||
spec = spec.timeout(timeout);
|
||||
}
|
||||
if let Some(dir) = working_dir {
|
||||
spec = spec.working_dir(dir);
|
||||
}
|
||||
for (key, value) in env_vars.into_iter().flatten() {
|
||||
spec = spec.env_var(key, value);
|
||||
}
|
||||
let controls = ExecControls {
|
||||
term: cancel_token,
|
||||
..ExecControls::default()
|
||||
};
|
||||
Ok(self.run_streaming(spec, controls).await?.result)
|
||||
}
|
||||
|
||||
/// Runs `spec` under fabro's policy, delivering output through
|
||||
/// `controls.sink` as it arrives.
|
||||
///
|
||||
/// The policy fills what the spec leaves open: the stop grace, the
|
||||
/// working directory, and the text output policy. The spec's environment
|
||||
/// goes to the provider as the caller composed it. The caller's
|
||||
/// `controls.term` is the `term` stop; the provider runs the grace and
|
||||
/// the `kill` itself. Output beyond `controls.retained_output_limit`
|
||||
/// (fabro's default when unset) is drained and counted, not kept.
|
||||
pub async fn run_streaming(
|
||||
&self,
|
||||
spec: ExecSpec,
|
||||
mut controls: ExecControls,
|
||||
) -> crate::Result<ExecStreamingResult> {
|
||||
let spec = self.apply_policy(spec);
|
||||
if controls.retained_output_limit.is_none() {
|
||||
controls.retained_output_limit = Some(DEFAULT_RETAINED_OUTPUT_BYTES);
|
||||
}
|
||||
Ok(self.exec.run_streaming(&spec, controls).await?)
|
||||
}
|
||||
|
||||
/// Launches a long-lived process with bidirectional stdio.
|
||||
///
|
||||
/// `command` is evaluated under the same non-login Bash contract before
|
||||
/// the shell replaces itself with the requested process. The returned
|
||||
/// handle terminates the process; dropping it does not.
|
||||
pub async fn spawn_stdio(
|
||||
&self,
|
||||
command: &str,
|
||||
working_dir: Option<&str>,
|
||||
env_vars: Option<&HashMap<String, String>>,
|
||||
) -> crate::Result<StdioProcess> {
|
||||
let mut spec = SpawnSpec::bash(format!("exec {command}"));
|
||||
if let Some(dir) = working_dir.or(self.working_dir.as_deref()) {
|
||||
spec = spec.working_dir(dir);
|
||||
}
|
||||
for (key, value) in env_vars.into_iter().flatten() {
|
||||
spec = spec.env_var(key, value);
|
||||
}
|
||||
Ok(self.exec.spawn_stdio(&spec).await?)
|
||||
}
|
||||
|
||||
/// Fills what a spec leaves open. The output policy has no "unset"
|
||||
/// state: the driver's default is raw, and fabro reads command output
|
||||
/// as text, so a spec still at that default gets
|
||||
/// [`OutputSanitization::StripAll`]; a caller that chose another policy
|
||||
/// keeps it. Long-lived stdio processes ([`Self::spawn_stdio`]) and PTY
|
||||
/// sessions stay raw, as the driver requires.
|
||||
fn apply_policy(&self, mut spec: ExecSpec) -> ExecSpec {
|
||||
if spec.stop_grace.is_none() {
|
||||
spec.stop_grace = Some(self.stop_grace);
|
||||
}
|
||||
if spec.working_dir.is_none() {
|
||||
spec.working_dir.clone_from(&self.working_dir);
|
||||
}
|
||||
if spec.output_sanitization == OutputSanitization::default() {
|
||||
spec.output_sanitization = OutputSanitization::StripAll;
|
||||
}
|
||||
spec
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver says how the command ended; fabro's event vocabulary has two
|
||||
/// stops. A timeout is the provider's deadline (the ladder ran for it); a
|
||||
/// cancelled or killed command was stopped by the caller's token, by a
|
||||
/// foreign `kill`, or by a provider-side abort — it did not finish and no
|
||||
/// deadline passed. `Exited`, or a provider that could not tell, is a
|
||||
/// completed process; nothing asserts success here.
|
||||
#[must_use]
|
||||
pub fn command_termination(termination: Termination) -> CommandTermination {
|
||||
match termination {
|
||||
Termination::TimedOut => CommandTermination::TimedOut,
|
||||
Termination::Cancelled | Termination::Killed => CommandTermination::Cancelled,
|
||||
_ => CommandTermination::Exited,
|
||||
}
|
||||
}
|
||||
|
||||
/// An exit code is only the command's own when it exited on its own. A
|
||||
/// stopped command may still report the shell's `128 + signal` (143 for a
|
||||
/// trapped `TERM`), which events must not present as a program result.
|
||||
#[must_use]
|
||||
pub fn program_exit_code(termination: Termination, exit_code: Option<i32>) -> Option<i32> {
|
||||
// `CommandTermination` is pebble's and non-exhaustive: only a command
|
||||
// that exited on its own owns its exit code.
|
||||
match command_termination(termination) {
|
||||
CommandTermination::Exited => exit_code,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fabro's reading of a driver [`ExecResult`]: the event-facing numbers,
|
||||
/// the redacted output tail, and the failure a non-zero exit is.
|
||||
pub trait ExecResultExt {
|
||||
/// The provider's measured run time in whole milliseconds.
|
||||
fn duration_ms(&self) -> u64;
|
||||
|
||||
/// The exit code when the command ended on its own; see
|
||||
/// [`program_exit_code`].
|
||||
fn program_exit_code(&self) -> Option<i32>;
|
||||
|
||||
/// Redacted tails of both streams, each bounded to
|
||||
/// `max_bytes_per_stream`. `None` when both streams are empty. Terminal
|
||||
/// control sequences were already stripped by the driver under
|
||||
/// [`SandboxExec`]'s output policy.
|
||||
fn redacted_output_tail(&self, max_bytes_per_stream: usize) -> Option<ExecOutputTail>;
|
||||
|
||||
/// [`Self::redacted_output_tail`] at fabro's event budget.
|
||||
fn default_redacted_output_tail(&self) -> Option<ExecOutputTail>;
|
||||
|
||||
/// The failure this result is, reported under `label`. The raw output
|
||||
/// stays behind the driver's [`ExecFailure`] accessors; `Display`
|
||||
/// carries only the label and the classified metadata.
|
||||
fn into_exec_error(self, label: impl Into<String>) -> crate::Error;
|
||||
|
||||
/// `Ok(self)` for a clean exit, the failure under `label` otherwise.
|
||||
fn into_result(self, label: impl Into<String>) -> crate::Result<ExecResult>;
|
||||
}
|
||||
|
||||
impl ExecResultExt for ExecResult {
|
||||
fn duration_ms(&self) -> u64 {
|
||||
u64::try_from(self.duration.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn program_exit_code(&self) -> Option<i32> {
|
||||
program_exit_code(self.termination, self.exit_code)
|
||||
}
|
||||
|
||||
fn redacted_output_tail(&self, max_bytes_per_stream: usize) -> Option<ExecOutputTail> {
|
||||
redacted_output_tail(
|
||||
&self.stdout_lossy(),
|
||||
&self.stderr_lossy(),
|
||||
max_bytes_per_stream,
|
||||
)
|
||||
}
|
||||
|
||||
fn default_redacted_output_tail(&self) -> Option<ExecOutputTail> {
|
||||
self.redacted_output_tail(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)
|
||||
}
|
||||
|
||||
fn into_exec_error(self, label: impl Into<String>) -> crate::Error {
|
||||
let failure = ExecFailure::new(
|
||||
label,
|
||||
self.termination,
|
||||
self.exit_code,
|
||||
self.stdout,
|
||||
self.stderr,
|
||||
)
|
||||
.with_duration(self.duration);
|
||||
crate::Error::from(sandbox_driver::Error::from(failure))
|
||||
}
|
||||
|
||||
fn into_result(self, label: impl Into<String>) -> crate::Result<ExecResult> {
|
||||
if self.success() {
|
||||
Ok(self)
|
||||
} else {
|
||||
Err(self.into_exec_error(label))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use sandbox_driver::{
|
||||
BASH_ENV_VAR, OutputSink, OutputStream, SandboxProvider as _, SandboxSource, SandboxSpec,
|
||||
TransportError,
|
||||
};
|
||||
use sandbox_driver_host::HostProvider;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::{fs, time};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct HostFixture {
|
||||
workspace: tempfile::TempDir,
|
||||
provider: HostProvider,
|
||||
sandbox: Arc<dyn sandbox_driver::Sandbox>,
|
||||
}
|
||||
|
||||
impl HostFixture {
|
||||
async fn new() -> Self {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let provider = HostProvider::new();
|
||||
let sandbox = provider
|
||||
.create(
|
||||
&SandboxSpec::new(SandboxSource::HostDirectory)
|
||||
.working_directory(workspace.path().display().to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Self {
|
||||
workspace,
|
||||
provider,
|
||||
sandbox,
|
||||
}
|
||||
}
|
||||
|
||||
fn exec(&self) -> SandboxExec<'_> {
|
||||
let _ = &self.provider;
|
||||
SandboxExec::new(self.sandbox.exec())
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(fixture: &HostFixture, command: &str) -> ExecResult {
|
||||
fixture
|
||||
.exec()
|
||||
.run(command, Some(Duration::from_secs(10)), None, None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn exec_result(
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
exit_code: Option<i32>,
|
||||
termination: Termination,
|
||||
duration_ms: u64,
|
||||
) -> ExecResult {
|
||||
let mut result =
|
||||
ExecResult::new(termination, exit_code, Duration::from_millis(duration_ms));
|
||||
result.stdout = stdout.as_bytes().to_vec();
|
||||
result.stderr = stderr.as_bytes().to_vec();
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_bash_source_and_reports_exit_code_and_streams() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let result = run(&fixture, "echo out; echo err >&2; exit 3").await;
|
||||
assert_eq!(result.stdout_lossy(), "out\n");
|
||||
assert_eq!(result.stderr_lossy(), "err\n");
|
||||
assert_eq!(result.exit_code, Some(3));
|
||||
assert_eq!(result.termination, Termination::Exited);
|
||||
assert!(!result.success());
|
||||
assert!(run(&fixture, "true").await.success());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_bash_only_syntax_in_a_clean_non_login_shell() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let result = run(
|
||||
&fixture,
|
||||
"[[ -n ${BASH_VERSION:-} ]] && shopt -q login_shell && echo login || echo nonlogin; \
|
||||
set -o | grep -E '^(errexit|pipefail)' | awk '{print $2}' | sort -u",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.stdout_lossy(), "nonlogin\noff\n", "{result:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_caller_supplied_bash_env_never_runs() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let startup = fixture.workspace.path().join("startup.sh");
|
||||
fs::write(&startup, "echo startup-source-loaded\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let env = HashMap::from([(BASH_ENV_VAR.to_string(), startup.display().to_string())]);
|
||||
let result = fixture
|
||||
.exec()
|
||||
.run(
|
||||
"echo body",
|
||||
Some(Duration::from_secs(10)),
|
||||
None,
|
||||
Some(&env),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.stdout_lossy(), "body\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_variables_reach_the_command_as_composed() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let env = HashMap::from([
|
||||
("FABRO_WORKER_TOKEN".to_string(), "deliberate".to_string()),
|
||||
("MY_VAR".to_string(), "ok".to_string()),
|
||||
]);
|
||||
let stdout = fixture
|
||||
.exec()
|
||||
.run("env", Some(Duration::from_secs(10)), None, Some(&env), None)
|
||||
.await
|
||||
.unwrap()
|
||||
.stdout_lossy();
|
||||
assert!(stdout.contains("FABRO_WORKER_TOKEN=deliberate"), "{stdout}");
|
||||
assert!(stdout.contains("MY_VAR=ok"), "{stdout}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_runs_the_ladder_and_reports_timed_out() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let started = Instant::now();
|
||||
let result = fixture
|
||||
.exec()
|
||||
.run(
|
||||
"sleep 10",
|
||||
Some(Duration::from_millis(200)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.termination, Termination::TimedOut);
|
||||
assert_eq!(result.program_exit_code(), None);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"sleep honours TERM, so KILL should not have been needed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_command_that_ignores_term_is_killed_after_the_grace_period() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let started = Instant::now();
|
||||
let result = fixture
|
||||
.exec()
|
||||
.with_stop_grace(Duration::from_millis(300))
|
||||
.run(
|
||||
"trap '' TERM; sleep 10",
|
||||
Some(Duration::from_millis(100)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.termination, Termination::TimedOut);
|
||||
let elapsed = started.elapsed();
|
||||
assert!(elapsed >= Duration::from_millis(400), "{elapsed:?}");
|
||||
assert!(elapsed < Duration::from_secs(5), "{elapsed:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_reports_cancelled() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let token = CancellationToken::new();
|
||||
let cancel = token.clone();
|
||||
tokio::spawn(async move {
|
||||
time::sleep(Duration::from_millis(100)).await;
|
||||
cancel.cancel();
|
||||
});
|
||||
let result = fixture
|
||||
.exec()
|
||||
.run(
|
||||
"sleep 10",
|
||||
Some(Duration::from_secs(30)),
|
||||
None,
|
||||
None,
|
||||
Some(token),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.termination, Termination::Cancelled);
|
||||
assert_eq!(result.program_exit_code(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_delivers_live_chunks_and_drains_past_the_retention_cap() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let seen = Arc::new(Mutex::new(Vec::<u8>::new()));
|
||||
let sink_seen = Arc::clone(&seen);
|
||||
let sink: OutputSink = Arc::new(move |stream, chunk| {
|
||||
let seen = Arc::clone(&sink_seen);
|
||||
Box::pin(async move {
|
||||
assert_eq!(stream, OutputStream::Stdout);
|
||||
seen.lock().unwrap().extend_from_slice(&chunk);
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
let streaming = fixture
|
||||
.exec()
|
||||
.run_streaming(
|
||||
ExecSpec::bash("for i in $(seq 1 200); do echo line-$i; done")
|
||||
.timeout(Duration::from_secs(10)),
|
||||
ExecControls {
|
||||
sink: Some(sink),
|
||||
retained_output_limit: Some(64),
|
||||
..ExecControls::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(streaming.result.success());
|
||||
assert!(streaming.live_streaming);
|
||||
assert!(streaming.streams_separated);
|
||||
let delivered = seen.lock().unwrap().len();
|
||||
assert_eq!(streaming.stdout_capture.observed_bytes, delivered);
|
||||
assert!(streaming.stdout_capture.omitted_bytes > 0);
|
||||
assert!(streaming.result.stdout.len() <= 64);
|
||||
assert!(streaming.result.stdout.starts_with(b"line-1\n"));
|
||||
assert!(streaming.result.stdout.ends_with(b"line-200\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdin_bytes_are_written_exactly_then_closed() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let stdin = b"first line\n$(touch must-not-run)\nlast line".to_vec();
|
||||
let streaming = fixture
|
||||
.exec()
|
||||
.run_streaming(
|
||||
ExecSpec::bash("cat; test -e must-not-run && echo RAN")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.stdin(stdin.clone()),
|
||||
ExecControls::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(streaming.result.stdout, stdin);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failing_output_sink_stops_the_command_with_an_error() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let sink: OutputSink = Arc::new(|_, _| {
|
||||
Box::pin(async {
|
||||
Err(sandbox_driver::Error::Transport(TransportError::new(
|
||||
"consumer gave up",
|
||||
)))
|
||||
})
|
||||
});
|
||||
let error = fixture
|
||||
.exec()
|
||||
.run_streaming(
|
||||
ExecSpec::bash("echo hello; sleep 5").timeout(Duration::from_secs(10)),
|
||||
ExecControls {
|
||||
sink: Some(sink),
|
||||
..ExecControls::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|streaming| streaming.result.termination);
|
||||
// The driver either surfaces the sink failure or reports the command
|
||||
// cancelled by it; both keep the consumer's error visible.
|
||||
match error {
|
||||
Ok(termination) => assert_eq!(termination, Termination::Cancelled),
|
||||
Err(error) => assert!(error.to_string().contains("consumer gave up"), "{error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_process_round_trips_lines_and_reports_exit() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let process = fixture.exec().spawn_stdio("cat", None, None).await.unwrap();
|
||||
let mut stdin = process.stdin;
|
||||
let mut stdout = BufReader::new(process.stdout);
|
||||
stdin.write_all(b"ping\n").await.unwrap();
|
||||
let mut line = String::new();
|
||||
stdout.read_line(&mut line).await.unwrap();
|
||||
assert_eq!(line, "ping\n");
|
||||
drop(stdin);
|
||||
let (termination, exit_code) = process.handle.wait().await;
|
||||
assert_eq!(termination, Termination::Exited);
|
||||
assert_eq!(exit_code, Some(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_process_terminates_on_request_and_keeps_a_stderr_tail() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let process = fixture
|
||||
.exec()
|
||||
.spawn_stdio("sh -c 'echo diag >&2; sleep 30'", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
time::sleep(Duration::from_millis(200)).await;
|
||||
process.handle.terminate().await;
|
||||
let (termination, _) = time::timeout(Duration::from_secs(5), process.handle.wait())
|
||||
.await
|
||||
.expect("terminate ends the process");
|
||||
assert_ne!(termination, Termination::Exited);
|
||||
assert_eq!(process.stderr_tail.to_string_lossy(), "diag\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termination_mapping_reads_the_drivers_verdict() {
|
||||
assert_eq!(
|
||||
command_termination(Termination::TimedOut),
|
||||
CommandTermination::TimedOut
|
||||
);
|
||||
assert_eq!(
|
||||
command_termination(Termination::Cancelled),
|
||||
CommandTermination::Cancelled
|
||||
);
|
||||
assert_eq!(
|
||||
command_termination(Termination::Killed),
|
||||
CommandTermination::Cancelled
|
||||
);
|
||||
assert_eq!(
|
||||
command_termination(Termination::Exited),
|
||||
CommandTermination::Exited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_exit_code_is_the_commands_own_only_when_it_exited() {
|
||||
assert_eq!(program_exit_code(Termination::Exited, Some(3)), Some(3));
|
||||
assert_eq!(program_exit_code(Termination::TimedOut, Some(143)), None);
|
||||
assert_eq!(program_exit_code(Termination::Cancelled, Some(143)), None);
|
||||
assert_eq!(program_exit_code(Termination::Killed, Some(137)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_result_reports_a_failure_under_its_label() {
|
||||
let result = exec_result(
|
||||
"out",
|
||||
"fatal: could not read Username",
|
||||
Some(128),
|
||||
Termination::Exited,
|
||||
42,
|
||||
);
|
||||
let error = result.into_result("git push").unwrap_err();
|
||||
let Some(sandbox_driver::Error::Exec(failure)) = error.driver() else {
|
||||
panic!("expected an exec failure, got {error:?}");
|
||||
};
|
||||
assert_eq!(failure.label(), "git push");
|
||||
assert_eq!(failure.exit_code(), Some(128));
|
||||
assert_eq!(failure.duration(), Some(Duration::from_millis(42)));
|
||||
assert!(
|
||||
!error.to_string().contains("could not read Username"),
|
||||
"raw output leaked into Display: {error}"
|
||||
);
|
||||
|
||||
let ok = exec_result("out", "", Some(0), Termination::Exited, 1);
|
||||
assert!(ok.into_result("true").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_tail_redacts_before_truncating() {
|
||||
let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
|
||||
let result = exec_result(
|
||||
&format!("{} {secret} done", "context ".repeat(20)),
|
||||
"",
|
||||
Some(1),
|
||||
Termination::Exited,
|
||||
1,
|
||||
);
|
||||
|
||||
let tail = result
|
||||
.redacted_output_tail(32)
|
||||
.expect("redacted output tail");
|
||||
let stdout = tail.stdout.expect("stdout tail");
|
||||
assert!(stdout.contains("REDACTED"), "{stdout}");
|
||||
assert!(!stdout.contains("F0gH3jE6pA"), "{stdout}");
|
||||
assert!(tail.stdout_truncated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_output_arrives_stripped_of_terminal_control_sequences() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let result = run(
|
||||
&fixture,
|
||||
"printf '\\033[31mred\\033[0m \\033]0;window-title\\007shown \\033(Bset \\033Mtwo-byte \
|
||||
\\bbackspace'",
|
||||
)
|
||||
.await;
|
||||
assert!(result.success(), "{result:?}");
|
||||
assert_eq!(result.stdout_lossy(), "red shown set two-byte backspace");
|
||||
|
||||
let tail = result
|
||||
.redacted_output_tail(1024)
|
||||
.expect("redacted output tail");
|
||||
assert_eq!(
|
||||
tail.stdout.as_deref(),
|
||||
Some("red shown set two-byte backspace")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_strips_output_unless_the_caller_chose_another_policy() {
|
||||
let fixture = HostFixture::new().await;
|
||||
let exec = fixture.exec();
|
||||
assert_eq!(
|
||||
exec.apply_policy(ExecSpec::bash("true"))
|
||||
.output_sanitization,
|
||||
OutputSanitization::StripAll
|
||||
);
|
||||
assert_eq!(
|
||||
exec.apply_policy(
|
||||
ExecSpec::bash("true").output_sanitization(OutputSanitization::StripAnsi)
|
||||
)
|
||||
.output_sanitization,
|
||||
OutputSanitization::StripAnsi
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_output_tail_serialized_budget_stays_below_40_kib() {
|
||||
let result = exec_result(
|
||||
&"o".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128),
|
||||
&"e".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128),
|
||||
Some(1),
|
||||
Termination::Exited,
|
||||
1,
|
||||
);
|
||||
|
||||
let tail = result.default_redacted_output_tail().expect("tail present");
|
||||
assert_eq!(
|
||||
tail.stdout.as_deref().map(str::len),
|
||||
Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)
|
||||
);
|
||||
assert_eq!(
|
||||
tail.stderr.as_deref().map(str::len),
|
||||
Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)
|
||||
);
|
||||
assert!(tail.stdout_truncated);
|
||||
assert!(tail.stderr_truncated);
|
||||
let serialized = serde_json::to_vec(&tail).expect("serialize tail");
|
||||
assert!(
|
||||
serialized.len() < 40 * 1024,
|
||||
"tail JSON was {} bytes",
|
||||
serialized.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
//! Fabro's retry budget for git operations against GitHub.
|
||||
//!
|
||||
//! The driver owns the retry loop and the decision
|
||||
//! ([`sandbox_driver::retry_git`]): a remote that cannot be reached is retried,
|
||||
//! a rejected credential is retried only while the token is fresh enough to
|
||||
//! still be replicating to GitHub's git endpoints, a static credential fails
|
||||
//! fast, and a command whose outcome is unknown is never replayed. Fabro keeps
|
||||
//! what is policy: how many attempts the host-side repository probe gets,
|
||||
//! how it paces them, and when the credential it runs with was minted.
|
||||
//!
|
||||
//! Retries reuse the same token on purpose. Replication of a given token
|
||||
//! only makes progress, so each attempt strictly improves the odds, while
|
||||
//! re-minting would restart the replication clock.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use fabro_github::token_source::TokenSnapshot;
|
||||
use sandbox_driver::{GitBackoff, GitCredentials, GitFailure, GitFailureKind, GitRetryPolicy};
|
||||
|
||||
/// The username GitHub expects with an installation token or PAT.
|
||||
const GITHUB_TOKEN_USERNAME: &str = "x-access-token";
|
||||
|
||||
/// Backoff between attempts: 3s, then 9s.
|
||||
///
|
||||
/// GitHub's guidance for token replication is to wait a few seconds and
|
||||
/// retry with the same token. Sub-second delays land inside the same
|
||||
/// replication window and spend an attempt for nothing.
|
||||
fn replication_backoff() -> GitBackoff {
|
||||
GitBackoff::new(Duration::from_secs(3), 3.0, Duration::from_secs(10))
|
||||
}
|
||||
|
||||
/// Host-side repository probes get 3 attempts at replication pacing, with
|
||||
/// no deadline of their own.
|
||||
#[must_use]
|
||||
pub fn repository_probe_policy() -> GitRetryPolicy {
|
||||
GitRetryPolicy::new(3, replication_backoff())
|
||||
}
|
||||
|
||||
/// Credentials carrying only the token's mint time, which is all the
|
||||
/// driver's decision reads for git that ran outside a sandbox. The token
|
||||
/// itself never leaves its snapshot.
|
||||
fn credential_age(snapshot: Option<&TokenSnapshot>) -> Option<GitCredentials> {
|
||||
let snapshot = snapshot?;
|
||||
let credentials = GitCredentials::new(GITHUB_TOKEN_USERNAME, "");
|
||||
Some(match snapshot.minted_at() {
|
||||
Some(minted_at) => credentials.minted_at(SystemTime::from(minted_at)),
|
||||
None => credentials,
|
||||
})
|
||||
}
|
||||
|
||||
/// The driver's failure for a rendered git message, so git that ran
|
||||
/// outside a sandbox (the host-side repository probe, the metadata push)
|
||||
/// is classified the same way as git the driver ran.
|
||||
fn classified_failure(operation: &str, message: &str) -> sandbox_driver::Error {
|
||||
sandbox_driver::Error::Git(GitFailure::classified(
|
||||
operation,
|
||||
GitFailureKind::from_message(message),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
/// Runs a host-side git operation that reports failures as rendered
|
||||
/// messages under `policy`, retrying while the driver's decision says the
|
||||
/// message is transient for the token behind `snapshot`. The final failure
|
||||
/// comes back as the operation's own message.
|
||||
pub async fn retry_git_messages<F, Fut>(
|
||||
policy: &GitRetryPolicy,
|
||||
snapshot: Option<&TokenSnapshot>,
|
||||
operation: &str,
|
||||
mut run: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<(), String>>,
|
||||
{
|
||||
let credentials = credential_age(snapshot);
|
||||
// The operation's own message is kept beside the classified failure the
|
||||
// driver decides on, so the caller reads the message it knows.
|
||||
let last_message = Mutex::new(None);
|
||||
let result = sandbox_driver::retry_git(
|
||||
policy,
|
||||
credentials.as_ref(),
|
||||
operation,
|
||||
|_attempt, _timeout| {
|
||||
let attempt = run();
|
||||
let last_message = &last_message;
|
||||
async move {
|
||||
attempt.await.map_err(|message| {
|
||||
let error = classified_failure(operation, &message);
|
||||
*last_message.lock().unwrap_or_else(PoisonError::into_inner) = Some(message);
|
||||
error
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(failure) => Err(last_message
|
||||
.into_inner()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.unwrap_or_else(|| failure.error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
use fabro_github::token_source::TokenProvenance;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn snapshot(age: Duration) -> TokenSnapshot {
|
||||
let now = Utc::now();
|
||||
TokenSnapshot {
|
||||
generation: 1,
|
||||
provenance: TokenProvenance::Minted {
|
||||
minted_at: now - chrono::Duration::from_std(age).unwrap(),
|
||||
expires_at: now + chrono::Duration::hours(1),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn static_snapshot() -> TokenSnapshot {
|
||||
TokenSnapshot {
|
||||
generation: 0,
|
||||
provenance: TokenProvenance::Static,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_backoff_paces_at_replication_intervals() {
|
||||
let backoff = repository_probe_policy().backoff;
|
||||
assert_eq!(backoff.delay_after(1), Duration::from_secs(3));
|
||||
assert_eq!(backoff.delay_after(2), Duration::from_secs(9));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn host_side_retries_keep_the_operations_own_message() {
|
||||
let calls = Mutex::new(0_u32);
|
||||
let result = retry_git_messages(
|
||||
&repository_probe_policy(),
|
||||
Some(&snapshot(Duration::from_secs(1))),
|
||||
"repository probe",
|
||||
|| {
|
||||
let attempt = {
|
||||
let mut calls = calls.lock().unwrap();
|
||||
*calls += 1;
|
||||
*calls
|
||||
};
|
||||
async move {
|
||||
if attempt < 3 {
|
||||
Err(format!("remote: Repository not found. (attempt {attempt})"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result, Ok(()));
|
||||
assert_eq!(*calls.lock().unwrap(), 3);
|
||||
|
||||
let permanent = retry_git_messages(
|
||||
&repository_probe_policy(),
|
||||
Some(&static_snapshot()),
|
||||
"repository probe",
|
||||
|| async { Err("remote: Repository not found.".to_owned()) },
|
||||
)
|
||||
.await;
|
||||
assert_eq!(permanent, Err("remote: Repository not found.".to_owned()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
pub mod environment;
|
||||
pub mod error;
|
||||
pub mod provider;
|
||||
pub mod sandbox;
|
||||
pub mod sandbox_spec;
|
||||
|
||||
mod clone_source;
|
||||
|
||||
mod git_policy;
|
||||
|
||||
mod managed_labels;
|
||||
|
||||
pub mod details;
|
||||
|
||||
pub mod driver;
|
||||
pub mod driver_sandbox;
|
||||
|
||||
pub mod exec;
|
||||
mod pebble_environment;
|
||||
|
||||
pub mod reconnect;
|
||||
mod redact;
|
||||
|
||||
pub mod docker;
|
||||
pub mod provider_sandbox;
|
||||
|
||||
pub mod daytona;
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
|
||||
pub use details::sandbox_details;
|
||||
pub use docker::check_docker_daemon;
|
||||
pub use driver::{DaytonaCredentials, ProviderAccess};
|
||||
pub use driver_sandbox::RunSandbox;
|
||||
pub use environment::{CloneRequest, sandbox_spec_for_environment};
|
||||
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
|
||||
pub use exec::{
|
||||
DEFAULT_RETAINED_OUTPUT_BYTES, DEFAULT_STOP_GRACE, ExecResultExt, SandboxExec,
|
||||
command_termination, program_exit_code,
|
||||
};
|
||||
pub use fabro_github::token_source::{
|
||||
InstallationTokenSource, ResolvedToken, TokenProvenance, TokenSnapshot,
|
||||
};
|
||||
pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};
|
||||
pub use git_policy::{repository_probe_policy, retry_git_messages};
|
||||
pub use provider::{SandboxInventory, SandboxLookupError};
|
||||
pub use provider_sandbox::{attach_provider_sandbox, local_sandbox, provider_sandbox};
|
||||
pub use reconnect::{open_terminal_for_run, reconnect_for_run};
|
||||
pub use redact::SecretRedactor;
|
||||
pub use sandbox::{
|
||||
DEFAULT_EXEC_OUTPUT_TAIL_BYTES, SandboxFile, SandboxWorkspaceLayout, redacted_output_tail,
|
||||
};
|
||||
/// Driver types a run sandbox speaks: what a command is and how it ended,
|
||||
/// what the file and search operations return, and what an environment
|
||||
/// asks of a sandbox. Re-exported so consumers need no direct driver
|
||||
/// dependency.
|
||||
pub use sandbox_driver::{
|
||||
CaptureStats, DirEntry, ExecControls, ExecFailure, ExecResult, ExecSpec, ExecStreamingResult,
|
||||
FileKind, GitRetryPolicy, GrepMatch, GrepOptions, LifecycleTimers, NetworkPolicy, OutputSink,
|
||||
OutputStream, PtySession, PtySize, Resources, SandboxSource, SandboxSpec as DriverSpec,
|
||||
StderrTail, StdioProcess, StdioProcessHandle, Termination, TransportError, WalkOptions,
|
||||
};
|
||||
pub use sandbox_spec::SandboxSpec;
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
//! The labels that mark a sandbox as fabro's.
|
||||
//!
|
||||
//! Providers share a daemon or an organization with every other
|
||||
//! application, so a persisted id is trusted only when the sandbox behind
|
||||
//! it still carries fabro's labels. The driver's ownership scope stamps them
|
||||
//! on every sandbox fabro creates, narrows every listing to them, and
|
||||
//! refuses to attach to or delete a sandbox without them; this module only
|
||||
//! says which labels those are.
|
||||
|
||||
use fabro_types::RunId;
|
||||
use sandbox_driver::Ownership;
|
||||
|
||||
pub(crate) const MANAGED_LABEL: &str = "sh.fabro.managed";
|
||||
pub(crate) const MANAGED_LABEL_VALUE: &str = "true";
|
||||
pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id";
|
||||
|
||||
/// Fabro's ownership of a sandbox: everything fabro manages, narrowed to
|
||||
/// one run when `run_id` is known.
|
||||
pub(crate) fn ownership(run_id: Option<&RunId>) -> Ownership {
|
||||
let ownership = Ownership::label(MANAGED_LABEL, MANAGED_LABEL_VALUE);
|
||||
match run_id {
|
||||
Some(run_id) => ownership.and_label(RUN_ID_LABEL, run_id.to_string()),
|
||||
None => ownership,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_types::RunId;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn conservative_daytona_key(key: &str) -> bool {
|
||||
key.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_'))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_label_keys_match_docker_and_use_conservative_ascii() {
|
||||
assert_eq!(MANAGED_LABEL, "sh.fabro.managed");
|
||||
assert_eq!(RUN_ID_LABEL, "sh.fabro.run_id");
|
||||
assert!(conservative_daytona_key(MANAGED_LABEL));
|
||||
assert!(conservative_daytona_key(RUN_ID_LABEL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_requires_fabro_and_the_run_when_known() {
|
||||
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
|
||||
let mut labels = BTreeMap::new();
|
||||
assert!(!ownership(None).owns(&labels));
|
||||
labels.insert(MANAGED_LABEL.to_string(), "true".to_string());
|
||||
assert!(ownership(None).owns(&labels));
|
||||
assert!(!ownership(Some(&run_id)).owns(&labels));
|
||||
labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string());
|
||||
assert!(ownership(Some(&run_id)).owns(&labels));
|
||||
|
||||
// Stamping overrides whatever a caller put under the reserved keys.
|
||||
let mut given = BTreeMap::from([
|
||||
("team".to_string(), "platform".to_string()),
|
||||
(MANAGED_LABEL.to_string(), "false".to_string()),
|
||||
(RUN_ID_LABEL.to_string(), "wrong".to_string()),
|
||||
]);
|
||||
ownership(Some(&run_id)).stamp(&mut given);
|
||||
assert_eq!(given.get("team").map(String::as_str), Some("platform"));
|
||||
assert_eq!(given.get(MANAGED_LABEL).map(String::as_str), Some("true"));
|
||||
assert_eq!(
|
||||
given.get(RUN_ID_LABEL).map(String::as_str),
|
||||
Some("01HY0000000000000000000000")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,674 +0,0 @@
|
|||
//! [`RunSandbox`] as the [`Environment`] pebble's coding agent runs in.
|
||||
//!
|
||||
//! Pebble's tools speak the `Environment` contract; fabro's one sandbox type
|
||||
//! speaks the sandbox driver's facets. This module is the mapping between the
|
||||
//! two, and nothing else: every path resolves the way fabro resolves it, every
|
||||
//! command runs through [`SandboxExec`](crate::SandboxExec) with fabro's
|
||||
//! exec policy, and every failure keeps its driver cause. There is no adapter
|
||||
//! struct; a run sandbox *is* an environment.
|
||||
//!
|
||||
//! Where the two contracts differ, pebble's wins here because the model reads
|
||||
//! pebble's: a glob that pebble rejects is rejected before the driver sees it,
|
||||
//! a directory listing is in tree order, and a command with no retention cap
|
||||
//! still drains under the driver's default buffer rather than without bound.
|
||||
//! Output a provider lost on its own transport
|
||||
//! ([`ExecStreamingResult::output_loss`]) has no slot in pebble's contract,
|
||||
//! so it is written where the model already reads: one line at the end of
|
||||
//! stderr.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use pebble_coding_agent::environment::support::{capture_stats, tree_order, validate_glob};
|
||||
use pebble_coding_agent::environment::{
|
||||
DirEntry, EnvResult, Environment, EnvironmentError, EnvironmentErrorKind, ExecOutcome,
|
||||
ExecOutputSink, ExecOutputStream, ExecRequest, ExecResult, GrepOptions,
|
||||
};
|
||||
use sandbox_driver::{
|
||||
ExecControls, ExecSpec, ExecStreamingResult, FileKind, OutputLoss, OutputSink, OutputStream,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::driver_sandbox::RunSandbox;
|
||||
use crate::exec::{ExecResultExt as _, command_termination, program_exit_code};
|
||||
use crate::sandbox;
|
||||
|
||||
#[async_trait]
|
||||
impl Environment for RunSandbox {
|
||||
fn working_directory(&self) -> &str {
|
||||
Self::working_directory(self)
|
||||
}
|
||||
|
||||
fn platform(&self) -> &str {
|
||||
Self::platform(self)
|
||||
}
|
||||
|
||||
fn os_version(&self) -> String {
|
||||
Self::os_version(self)
|
||||
}
|
||||
|
||||
async fn read_file_bytes(&self, path: &str) -> EnvResult<Vec<u8>> {
|
||||
Self::read_file_bytes(self, path)
|
||||
.await
|
||||
.map_err(|error| environment_error(&format!("Failed to read {path}"), error))
|
||||
}
|
||||
|
||||
async fn write_file(&self, path: &str, content: &str) -> EnvResult<()> {
|
||||
Self::write_file(self, path, content)
|
||||
.await
|
||||
.map_err(|error| environment_error(&format!("Failed to write {path}"), error))
|
||||
}
|
||||
|
||||
async fn rename_file(&self, source: &str, destination: &str) -> EnvResult<()> {
|
||||
let resolved_source = self.resolve_for_environment(source);
|
||||
let resolved_destination = self.resolve_for_environment(destination);
|
||||
if !Self::file_exists(self, source)
|
||||
.await
|
||||
.map_err(|error| environment_error(&format!("Failed to stat {source}"), error))?
|
||||
{
|
||||
return Err(EnvironmentError::new(
|
||||
EnvironmentErrorKind::NotFound,
|
||||
format!("Failed to move {source}: file does not exist"),
|
||||
));
|
||||
}
|
||||
// The same path spelled twice is a move to itself, which must leave
|
||||
// the file where it is. Aliases the sandbox's own filesystem would
|
||||
// resolve (a symlinked parent, a hard link) are not checked: fabro has
|
||||
// no remote `realpath`, and a driver `mv a a` is a no-op anyway.
|
||||
if normalize(&resolved_source) == normalize(&resolved_destination) {
|
||||
return Ok(());
|
||||
}
|
||||
let handle = self
|
||||
.handle()
|
||||
.map_err(|error| environment_error("Sandbox is not initialized", error))?;
|
||||
// The destination's parent is created first, and a parent that is a
|
||||
// file fails here, before anything has moved, so the source stays
|
||||
// intact as the contract requires.
|
||||
if let Some(parent) = parent_directory(&resolved_destination) {
|
||||
handle.fs().create_dir(parent).await.map_err(|error| {
|
||||
environment_error(
|
||||
&format!("Failed to create the parent directory of {destination}"),
|
||||
crate::Error::from(error),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
handle
|
||||
.fs()
|
||||
.rename(&resolved_source, &resolved_destination)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
environment_error(
|
||||
&format!("Failed to move {source} to {destination}"),
|
||||
crate::Error::from(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_file(&self, path: &str) -> EnvResult<()> {
|
||||
// The driver's delete is idempotent; pebble's is a `remove_file`, which
|
||||
// reports a path that is not there.
|
||||
if !Self::file_exists(self, path)
|
||||
.await
|
||||
.map_err(|error| environment_error(&format!("Failed to stat {path}"), error))?
|
||||
{
|
||||
return Err(EnvironmentError::new(
|
||||
EnvironmentErrorKind::NotFound,
|
||||
format!("Failed to delete {path}: file does not exist"),
|
||||
));
|
||||
}
|
||||
Self::delete_file(self, path)
|
||||
.await
|
||||
.map_err(|error| environment_error(&format!("Failed to delete {path}"), error))
|
||||
}
|
||||
|
||||
async fn file_exists(&self, path: &str) -> EnvResult<bool> {
|
||||
Self::file_exists(self, path)
|
||||
.await
|
||||
.map_err(|error| environment_error(&format!("Failed to stat {path}"), error))
|
||||
}
|
||||
|
||||
async fn list_directory(&self, path: &str, depth: Option<usize>) -> EnvResult<Vec<DirEntry>> {
|
||||
let mut entries: Vec<DirEntry> = Self::list_directory(self, path, depth)
|
||||
.await
|
||||
.map_err(|error| environment_error(&format!("Failed to list {path}"), error))?
|
||||
.into_iter()
|
||||
.map(|entry| DirEntry {
|
||||
is_dir: entry.kind == FileKind::Directory,
|
||||
size: (entry.kind == FileKind::File)
|
||||
.then_some(entry.size)
|
||||
.flatten(),
|
||||
name: entry.path,
|
||||
})
|
||||
.collect();
|
||||
// The driver lists in flat lexicographic order of the whole relative
|
||||
// path, where `foo-bar` sorts between `foo` and `foo/x`. Pebble lists
|
||||
// in tree order, and says how.
|
||||
tree_order(&mut entries);
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn grep(
|
||||
&self,
|
||||
pattern: &str,
|
||||
path: &str,
|
||||
options: &GrepOptions,
|
||||
) -> EnvResult<Vec<String>> {
|
||||
let mut driver_options = sandbox_driver::GrepOptions::default();
|
||||
driver_options.case_insensitive = options.case_insensitive;
|
||||
driver_options.max_matches = options.max_results;
|
||||
driver_options.include = options.glob_filter.clone();
|
||||
let matches = Self::grep(self, pattern, path, &driver_options)
|
||||
.await
|
||||
.map_err(|error| environment_error("Failed to search file contents", error))?;
|
||||
Ok(matches
|
||||
.into_iter()
|
||||
.map(|found| format!("{}:{}:{}", found.path, found.line_number, found.line))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn glob(&self, pattern: &str, path: Option<&str>) -> EnvResult<Vec<String>> {
|
||||
// Validated by pebble's own grammar before the driver sees the
|
||||
// pattern, so the reason reaches the model in pebble's words and the
|
||||
// patterns pebble rejects are rejected even where fabro's glob would
|
||||
// accept them.
|
||||
validate_glob(pattern)?;
|
||||
Self::glob(self, pattern, path)
|
||||
.await
|
||||
.map_err(|error| environment_error("Failed to match files", error))
|
||||
}
|
||||
|
||||
async fn exec(&self, request: ExecRequest<'_>) -> EnvResult<ExecOutcome> {
|
||||
let ExecRequest {
|
||||
command,
|
||||
timeout_ms,
|
||||
working_dir,
|
||||
env_vars,
|
||||
cancel_token,
|
||||
output_bytes_cap,
|
||||
output_sink,
|
||||
} = request;
|
||||
let mut spec = ExecSpec::bash(command).no_timeout();
|
||||
if let Some(timeout_ms) = timeout_ms {
|
||||
spec = spec.timeout(Duration::from_millis(timeout_ms));
|
||||
}
|
||||
if let Some(dir) = working_dir {
|
||||
spec = spec.working_dir(dir);
|
||||
}
|
||||
for (key, value) in env_vars.into_iter().flatten() {
|
||||
spec = spec.env_var(key, value);
|
||||
}
|
||||
let controls = ExecControls {
|
||||
term: cancel_token,
|
||||
sink: output_sink.map(adapt_output_sink),
|
||||
// `None` asks pebble for no cap at all. Fabro's exec policy fills
|
||||
// its default buffer when the cap is unset, so a command with no
|
||||
// cap drains under that default rather than without bound; the
|
||||
// capture counts still say what was dropped.
|
||||
retained_output_limit: output_bytes_cap,
|
||||
..ExecControls::default()
|
||||
};
|
||||
let streaming = self
|
||||
.exec_command_streaming(spec, controls)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
let kind = match error.driver() {
|
||||
Some(sandbox_driver::Error::Transport(_)) => EnvironmentErrorKind::Io,
|
||||
Some(sandbox_driver::Error::Unsupported { .. }) => {
|
||||
EnvironmentErrorKind::Unsupported
|
||||
}
|
||||
_ => EnvironmentErrorKind::Spawn,
|
||||
};
|
||||
EnvironmentError::with_source(kind, "Failed to run the command", error)
|
||||
})?;
|
||||
Ok(exec_outcome(
|
||||
streaming,
|
||||
output_bytes_cap,
|
||||
program_name(command),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pebble's outcome for a finished command: the driver's result read the way
|
||||
/// fabro reads it, plus the provider's own output loss written where the
|
||||
/// model reads stderr.
|
||||
///
|
||||
/// A provider whose transport tore (Daytona's text-only toolbox) completes
|
||||
/// the command and reports what it discarded in
|
||||
/// [`ExecStreamingResult::output_loss`] rather than failing it. The frames
|
||||
/// are gone, the stream they belonged to is unknown, and the counts are of
|
||||
/// encoded bytes, so they cannot be folded into either stream's capture
|
||||
/// accounting without guessing; the loss is one line at the end of stderr,
|
||||
/// where the model and the run log see it, and one log event for the
|
||||
/// operator. The driver's `truncated` flags on the captures already say the
|
||||
/// counts undercount.
|
||||
fn exec_outcome(
|
||||
streaming: ExecStreamingResult,
|
||||
output_bytes_cap: Option<usize>,
|
||||
program: &str,
|
||||
) -> ExecOutcome {
|
||||
let loss = streaming.output_loss;
|
||||
let result = streaming.result;
|
||||
let mut stderr = result.stderr_lossy();
|
||||
if loss.is_lossy() {
|
||||
warn!(
|
||||
program = %program,
|
||||
dropped_frames = loss.dropped_frames,
|
||||
dropped_bytes = loss.dropped_bytes,
|
||||
"Sandbox provider dropped command output"
|
||||
);
|
||||
if !stderr.is_empty() && !stderr.ends_with('\n') {
|
||||
stderr.push('\n');
|
||||
}
|
||||
stderr.push_str(&output_loss_line(loss));
|
||||
}
|
||||
ExecOutcome {
|
||||
result: ExecResult {
|
||||
stdout: result.stdout_lossy(),
|
||||
stderr,
|
||||
exit_code: program_exit_code(result.termination, result.exit_code),
|
||||
termination: command_termination(result.termination),
|
||||
duration_ms: result.duration_ms(),
|
||||
},
|
||||
streams_separated: streaming.streams_separated,
|
||||
stdout_capture: capture_stats(streaming.stdout_capture.observed_bytes, output_bytes_cap),
|
||||
stderr_capture: capture_stats(streaming.stderr_capture.observed_bytes, output_bytes_cap),
|
||||
}
|
||||
}
|
||||
|
||||
/// The line stderr ends with when the provider dropped output.
|
||||
fn output_loss_line(loss: OutputLoss) -> String {
|
||||
format!(
|
||||
"[sandbox] {} output frame(s), {} bytes dropped by the provider\n",
|
||||
loss.dropped_frames, loss.dropped_bytes
|
||||
)
|
||||
}
|
||||
|
||||
/// Bytes of a command's first word a log event carries.
|
||||
const PROGRAM_NAME_BYTES: usize = 64;
|
||||
|
||||
/// The word a command starts with, bounded, for a log event that must not
|
||||
/// carry the command itself.
|
||||
fn program_name(command: &str) -> &str {
|
||||
let word = command.split_whitespace().next().unwrap_or_default();
|
||||
&word[..word.floor_char_boundary(PROGRAM_NAME_BYTES)]
|
||||
}
|
||||
|
||||
impl RunSandbox {
|
||||
/// A caller path as the driver will see it: fabro's working directory
|
||||
/// applied where fabro applies it, and nothing more.
|
||||
fn resolve_for_environment(&self, path: &str) -> String {
|
||||
sandbox::resolve_path(path, Self::working_directory(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pebble's glob grammar, beyond what fabro's glob already rejects.
|
||||
///
|
||||
/// A path with its redundant separators and `.` segments removed, for
|
||||
/// deciding whether two spellings name the same file.
|
||||
fn normalize(path: &str) -> String {
|
||||
let absolute = path.starts_with('/');
|
||||
let joined = path
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty() && *segment != ".")
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
if absolute {
|
||||
format!("/{joined}")
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory a path is in, when the path names one.
|
||||
fn parent_directory(path: &str) -> Option<&str> {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let (parent, _) = trimmed.rsplit_once('/')?;
|
||||
if parent.is_empty() {
|
||||
return Some("/");
|
||||
}
|
||||
Some(parent)
|
||||
}
|
||||
|
||||
/// Feeds the driver's asynchronous chunk callback into pebble's synchronous
|
||||
/// sink.
|
||||
fn adapt_output_sink(sink: ExecOutputSink) -> OutputSink {
|
||||
Arc::new(move |stream, chunk: Vec<u8>| {
|
||||
let stream = match stream {
|
||||
OutputStream::Stdout => ExecOutputStream::Stdout,
|
||||
OutputStream::Stderr => ExecOutputStream::Stderr,
|
||||
};
|
||||
sink(stream, &chunk);
|
||||
Box::pin(async { Ok(()) })
|
||||
})
|
||||
}
|
||||
|
||||
/// A sandbox failure as pebble classifies it, keeping the driver cause.
|
||||
fn environment_error(message: &str, error: crate::Error) -> EnvironmentError {
|
||||
let kind = match error.driver() {
|
||||
Some(sandbox_driver::Error::NotFound { .. }) => EnvironmentErrorKind::NotFound,
|
||||
Some(sandbox_driver::Error::Unsupported { .. }) => EnvironmentErrorKind::Unsupported,
|
||||
_ => EnvironmentErrorKind::Io,
|
||||
};
|
||||
EnvironmentError::with_source(kind, message, error)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use pebble_coding_agent::test_support::EnvironmentContract;
|
||||
use sandbox_driver::{
|
||||
Capabilities, Exec, Filesystem, PlatformInfo, Sandbox, SandboxId, SandboxStatus, Search,
|
||||
SpawnSpec, StdioProcess, Termination,
|
||||
};
|
||||
use sandbox_driver_testing::ScriptedSandbox;
|
||||
|
||||
use super::*;
|
||||
use crate::local_sandbox;
|
||||
use crate::test_support::{MockSandbox, exec_result};
|
||||
|
||||
/// The run sandbox over the driver's Host provider, in a directory that
|
||||
/// goes away with the test.
|
||||
async fn host_environment() -> (tempfile::TempDir, RunSandbox) {
|
||||
let directory = tempfile::tempdir().expect("a temporary directory");
|
||||
let sandbox = local_sandbox(directory.path().to_path_buf())
|
||||
.await
|
||||
.expect("a local sandbox");
|
||||
(directory, sandbox)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_files_satisfy_pebbles_environment_contract() {
|
||||
let (_directory, sandbox) = host_environment().await;
|
||||
EnvironmentContract::new(&sandbox, "contract")
|
||||
.verify_files()
|
||||
.await
|
||||
.expect("file contract");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_search_satisfies_pebbles_environment_contract() {
|
||||
let (_directory, sandbox) = host_environment().await;
|
||||
EnvironmentContract::new(&sandbox, "contract")
|
||||
.verify_search()
|
||||
.await
|
||||
.expect("search contract");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_commands_satisfy_pebbles_environment_contract() {
|
||||
let (_directory, sandbox) = host_environment().await;
|
||||
EnvironmentContract::new(&sandbox, "contract")
|
||||
.verify_commands()
|
||||
.await
|
||||
.expect("command contract");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_directory_listing_is_in_tree_order() {
|
||||
let (directory, sandbox) = host_environment().await;
|
||||
for name in ["foo/x.txt", "foo-bar/y.txt", "foo.txt"] {
|
||||
Environment::write_file(&sandbox, name, "content")
|
||||
.await
|
||||
.expect("fixture");
|
||||
}
|
||||
let names: Vec<String> = Environment::list_directory(&sandbox, ".", Some(2))
|
||||
.await
|
||||
.expect("listing")
|
||||
.into_iter()
|
||||
.map(|entry| entry.name)
|
||||
.collect();
|
||||
assert_eq!(names, [
|
||||
"foo",
|
||||
"foo/x.txt",
|
||||
"foo-bar",
|
||||
"foo-bar/y.txt",
|
||||
"foo.txt"
|
||||
]);
|
||||
drop(directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_spelled_two_ways_is_one_path() {
|
||||
assert_eq!(normalize("/work//a/./b.txt"), "/work/a/b.txt");
|
||||
assert_eq!(parent_directory("/work/a/b.txt"), Some("/work/a"));
|
||||
assert_eq!(parent_directory("/b.txt"), Some("/"));
|
||||
assert_eq!(parent_directory("b.txt"), None);
|
||||
}
|
||||
|
||||
fn request(command: &str) -> ExecRequest<'_> {
|
||||
ExecRequest {
|
||||
command,
|
||||
timeout_ms: Some(10_000),
|
||||
working_dir: None,
|
||||
env_vars: None,
|
||||
cancel_token: None,
|
||||
output_bytes_cap: None,
|
||||
output_sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn output_loss(dropped_frames: u64, dropped_bytes: u64) -> OutputLoss {
|
||||
let mut loss = OutputLoss::default();
|
||||
loss.dropped_frames = dropped_frames;
|
||||
loss.dropped_bytes = dropped_bytes;
|
||||
loss
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lossless_command_hands_back_stderr_as_the_provider_wrote_it() {
|
||||
let mock = MockSandbox {
|
||||
exec_result: exec_result(
|
||||
"built\n",
|
||||
"warning: unused\n",
|
||||
Some(0),
|
||||
Termination::Exited,
|
||||
7,
|
||||
),
|
||||
..MockSandbox::linux()
|
||||
};
|
||||
let outcome = Environment::exec(&*mock.sandbox(), request("cargo build"))
|
||||
.await
|
||||
.expect("a scripted command");
|
||||
assert_eq!(outcome.result.stdout, "built\n");
|
||||
assert_eq!(outcome.result.stderr, "warning: unused\n");
|
||||
assert_eq!(outcome.result.exit_code, Some(0));
|
||||
assert_eq!(
|
||||
outcome.stderr_capture.observed_bytes,
|
||||
"warning: unused\n".len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_provider_output_loss_ends_stderr_with_one_line() {
|
||||
let mut streaming = ExecStreamingResult::new(exec_result(
|
||||
"built\n",
|
||||
"warning: torn",
|
||||
Some(1),
|
||||
Termination::Exited,
|
||||
7,
|
||||
));
|
||||
streaming.output_loss = output_loss(2, 4096);
|
||||
|
||||
let outcome = exec_outcome(streaming, Some(1024), "cargo");
|
||||
|
||||
assert_eq!(outcome.result.stdout, "built\n");
|
||||
assert_eq!(
|
||||
outcome.result.stderr,
|
||||
"warning: torn\n[sandbox] 2 output frame(s), 4096 bytes dropped by the provider\n"
|
||||
);
|
||||
assert_eq!(outcome.result.exit_code, Some(1));
|
||||
assert_eq!(outcome.result.duration_ms, 7);
|
||||
// The loss is not folded into either stream's accounting.
|
||||
assert_eq!(outcome.stdout_capture.observed_bytes, "built\n".len());
|
||||
assert_eq!(outcome.stderr_capture.observed_bytes, "warning: torn".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_provider_output_loss_with_no_stderr_is_the_line_alone() {
|
||||
let mut streaming =
|
||||
ExecStreamingResult::new(exec_result("", "", Some(0), Termination::Exited, 1));
|
||||
streaming.output_loss = output_loss(1, 80);
|
||||
let outcome = exec_outcome(streaming, None, "sh");
|
||||
assert_eq!(
|
||||
outcome.result.stderr,
|
||||
"[sandbox] 1 output frame(s), 80 bytes dropped by the provider\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_event_names_the_first_word_of_a_command_bounded() {
|
||||
assert_eq!(program_name("cargo build --release"), "cargo");
|
||||
assert_eq!(program_name(" \n ls"), "ls");
|
||||
assert_eq!(program_name(""), "");
|
||||
let long = "x".repeat(PROGRAM_NAME_BYTES + 10);
|
||||
assert_eq!(program_name(&long).len(), PROGRAM_NAME_BYTES);
|
||||
let multibyte = "é".repeat(PROGRAM_NAME_BYTES);
|
||||
assert!(program_name(&multibyte).len() <= PROGRAM_NAME_BYTES);
|
||||
}
|
||||
|
||||
/// The driver's scripted sandbox with an exec facet that reports a
|
||||
/// provider output loss on every command, as Daytona does after a torn
|
||||
/// frame. The scripted double itself has no knob for the loss.
|
||||
struct LossySandbox {
|
||||
inner: Arc<ScriptedSandbox>,
|
||||
exec: LossyExec,
|
||||
}
|
||||
|
||||
struct LossyExec {
|
||||
inner: Arc<ScriptedSandbox>,
|
||||
loss: OutputLoss,
|
||||
}
|
||||
|
||||
impl LossySandbox {
|
||||
fn new(inner: Arc<ScriptedSandbox>, loss: OutputLoss) -> Self {
|
||||
Self {
|
||||
exec: LossyExec {
|
||||
inner: Arc::clone(&inner),
|
||||
loss,
|
||||
},
|
||||
inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Exec for LossyExec {
|
||||
async fn run(&self, spec: &ExecSpec) -> sandbox_driver::Result<sandbox_driver::ExecResult> {
|
||||
self.inner.scripted_exec().run(spec).await
|
||||
}
|
||||
|
||||
async fn run_streaming(
|
||||
&self,
|
||||
spec: &ExecSpec,
|
||||
controls: ExecControls,
|
||||
) -> sandbox_driver::Result<ExecStreamingResult> {
|
||||
let mut streaming = self
|
||||
.inner
|
||||
.scripted_exec()
|
||||
.run_streaming(spec, controls)
|
||||
.await?;
|
||||
streaming.output_loss = self.loss;
|
||||
streaming.stdout_capture.truncated = true;
|
||||
streaming.stderr_capture.truncated = true;
|
||||
Ok(streaming)
|
||||
}
|
||||
|
||||
async fn spawn_stdio(&self, spec: &SpawnSpec) -> sandbox_driver::Result<StdioProcess> {
|
||||
self.inner.scripted_exec().spawn_stdio(spec).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Sandbox for LossySandbox {
|
||||
fn id(&self) -> &SandboxId {
|
||||
self.inner.id()
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> &Capabilities {
|
||||
// The scripted sandbox's builder method of the same name shadows
|
||||
// the trait's.
|
||||
Sandbox::capabilities(&*self.inner)
|
||||
}
|
||||
|
||||
async fn describe(&self) -> sandbox_driver::Result<SandboxStatus> {
|
||||
self.inner.describe().await
|
||||
}
|
||||
|
||||
fn working_directory(&self) -> &str {
|
||||
self.inner.working_directory()
|
||||
}
|
||||
|
||||
async fn environment(&self) -> sandbox_driver::Result<BTreeMap<String, String>> {
|
||||
self.inner.environment().await
|
||||
}
|
||||
|
||||
fn runtime_directory(&self) -> Option<&str> {
|
||||
Sandbox::runtime_directory(&*self.inner)
|
||||
}
|
||||
|
||||
async fn platform_info(&self) -> sandbox_driver::Result<PlatformInfo> {
|
||||
self.inner.platform_info().await
|
||||
}
|
||||
|
||||
async fn start(&self) -> sandbox_driver::Result<()> {
|
||||
self.inner.start().await
|
||||
}
|
||||
|
||||
async fn stop(&self) -> sandbox_driver::Result<()> {
|
||||
self.inner.stop().await
|
||||
}
|
||||
|
||||
async fn delete(&self) -> sandbox_driver::Result<()> {
|
||||
self.inner.delete().await
|
||||
}
|
||||
|
||||
fn exec(&self) -> &dyn Exec {
|
||||
&self.exec
|
||||
}
|
||||
|
||||
fn fs(&self) -> &dyn Filesystem {
|
||||
self.inner.fs()
|
||||
}
|
||||
|
||||
fn provider_search(&self) -> Option<&dyn Search> {
|
||||
self.inner.provider_search()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lossy_command_tells_the_model_what_the_provider_dropped() {
|
||||
let scripted =
|
||||
Arc::new(
|
||||
ScriptedSandbox::with_id_and_working_dir("lossy", "/work")
|
||||
.platform(PlatformInfo::new("linux", "x86_64", "Linux 6.1.0")),
|
||||
);
|
||||
scripted.scripted_exec().set_default(exec_result(
|
||||
"built\n",
|
||||
"warning: torn",
|
||||
Some(0),
|
||||
Termination::Exited,
|
||||
7,
|
||||
));
|
||||
let sandbox = RunSandbox::new_with_platform(
|
||||
SandboxProviderKind::DAYTONA,
|
||||
Arc::new(LossySandbox::new(scripted, output_loss(3, 512))),
|
||||
"linux",
|
||||
"Linux 6.1.0",
|
||||
);
|
||||
|
||||
let outcome = Environment::exec(&sandbox, request("cargo build"))
|
||||
.await
|
||||
.expect("a lossy command completes rather than fails");
|
||||
|
||||
assert_eq!(outcome.result.stdout, "built\n");
|
||||
assert_eq!(
|
||||
outcome.result.stderr,
|
||||
"warning: torn\n[sandbox] 3 output frame(s), 512 bytes dropped by the provider\n"
|
||||
);
|
||||
assert_eq!(outcome.result.exit_code, Some(0));
|
||||
assert!(outcome.streams_separated);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
//! Fabro's inventory of the sandboxes it manages, across the providers a
|
||||
//! server has configured.
|
||||
//!
|
||||
//! Every entry is a sandbox-driver provider narrowed by fabro's ownership
|
||||
//! labels, so a listing shows only the sandboxes fabro created and an
|
||||
//! attach to anything else is refused. A provider connects on first use:
|
||||
//! the inventory is assembled synchronously at startup, and a provider that
|
||||
//! is down surfaces as a lookup error rather than a startup failure. The
|
||||
//! `local` kind has an entry too, so a caller can ask whether the kind is
|
||||
//! ready, but its sandboxes are directories the run record names and there
|
||||
//! is nothing to list.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_types::settings::server::ServerSandboxProviderSettings;
|
||||
use fabro_types::{
|
||||
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind,
|
||||
SandboxProviderLookupError,
|
||||
};
|
||||
use fabro_util::error::collect_chain;
|
||||
use futures::future::join_all;
|
||||
use sandbox_driver::{
|
||||
Error as DriverError, OwnedProvider, SandboxFilter, SandboxId,
|
||||
SandboxProvider as DriverProvider, SandboxState,
|
||||
};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::driver::{ConnectedProvider, ProviderConnectOptions, connect_provider};
|
||||
use crate::managed_labels;
|
||||
|
||||
/// The sandboxes fabro manages, by provider.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SandboxInventory {
|
||||
entries: Vec<Arc<InventoryEntry>>,
|
||||
}
|
||||
|
||||
struct InventoryEntry {
|
||||
kind: SandboxProviderKind,
|
||||
connection: Connection,
|
||||
}
|
||||
|
||||
enum Connection {
|
||||
/// Sandboxes on this host are directories the run record names;
|
||||
/// there is nothing to list.
|
||||
HostDirectories,
|
||||
Connected(Arc<dyn DriverProvider>),
|
||||
/// Connected through [`connect_provider`] on first use.
|
||||
Lazy(Box<LazyConnection>),
|
||||
}
|
||||
|
||||
struct LazyConnection {
|
||||
settings: ServerSandboxProviderSettings,
|
||||
options: ProviderConnectOptions,
|
||||
provider: OnceCell<Arc<dyn DriverProvider>>,
|
||||
}
|
||||
|
||||
impl SandboxInventory {
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A kind whose sandboxes are directories on this host: ready to run,
|
||||
/// nothing to list.
|
||||
#[must_use]
|
||||
pub fn with_host_directories(self, kind: SandboxProviderKind) -> Self {
|
||||
self.with_entry(kind, Connection::HostDirectories)
|
||||
}
|
||||
|
||||
/// A provider already connected, tagged with the kind fabro persists
|
||||
/// for it.
|
||||
#[must_use]
|
||||
pub fn with_connected(self, connected: ConnectedProvider) -> Self {
|
||||
self.with_entry(
|
||||
connected.kind,
|
||||
Connection::Connected(owned(connected.provider)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A provider connected through [`connect_provider`] on first use.
|
||||
#[must_use]
|
||||
pub fn with_lazy(
|
||||
self,
|
||||
kind: SandboxProviderKind,
|
||||
settings: ServerSandboxProviderSettings,
|
||||
options: ProviderConnectOptions,
|
||||
) -> Self {
|
||||
self.with_entry(
|
||||
kind,
|
||||
Connection::Lazy(Box::new(LazyConnection {
|
||||
settings,
|
||||
options,
|
||||
provider: OnceCell::new(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn with_entry(mut self, kind: SandboxProviderKind, connection: Connection) -> Self {
|
||||
self.entries
|
||||
.push(Arc::new(InventoryEntry { kind, connection }));
|
||||
self
|
||||
}
|
||||
|
||||
/// The provider kinds this inventory covers.
|
||||
pub fn kinds(&self) -> impl Iterator<Item = &SandboxProviderKind> {
|
||||
self.entries.iter().map(|entry| &entry.kind)
|
||||
}
|
||||
|
||||
pub async fn list_managed(&self) -> SandboxListResponse {
|
||||
let results = join_all(
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|entry| async move { (&entry.kind, entry.list().await) }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut data = Vec::new();
|
||||
let mut provider_errors = Vec::new();
|
||||
for (kind, result) in results {
|
||||
match result {
|
||||
Ok(mut sandboxes) => data.append(&mut sandboxes),
|
||||
Err(err) => provider_errors.push(provider_error(kind.clone(), &err)),
|
||||
}
|
||||
}
|
||||
|
||||
SandboxListResponse {
|
||||
data,
|
||||
meta: SandboxListMeta { provider_errors },
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_managed_by_native_id(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<SandboxInfo, SandboxLookupError> {
|
||||
let results = join_all(
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|entry| async move { (&entry.kind, entry.get(id).await) }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut matches = Vec::new();
|
||||
let mut provider_errors = Vec::new();
|
||||
for (kind, result) in results {
|
||||
match result {
|
||||
Ok(Some(sandbox)) => matches.push(sandbox),
|
||||
Ok(None) => {}
|
||||
Err(err) => provider_errors.push(provider_error(kind.clone(), &err)),
|
||||
}
|
||||
}
|
||||
|
||||
match matches.len() {
|
||||
1 => Ok(matches.remove(0)),
|
||||
0 if provider_errors.is_empty() => {
|
||||
Err(SandboxLookupError::NotFound { id: id.to_string() })
|
||||
}
|
||||
0 => Err(SandboxLookupError::ProviderUnavailable {
|
||||
id: id.to_string(),
|
||||
provider_errors,
|
||||
}),
|
||||
_ => Err(SandboxLookupError::Conflict {
|
||||
id: id.to_string(),
|
||||
providers: matches
|
||||
.into_iter()
|
||||
.map(|sandbox| sandbox.provider)
|
||||
.collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InventoryEntry {
|
||||
/// The provider narrowed to fabro's sandboxes, connected on first use;
|
||||
/// `None` when the kind has nothing to list.
|
||||
async fn provider(&self) -> crate::Result<Option<&Arc<dyn DriverProvider>>> {
|
||||
match &self.connection {
|
||||
Connection::HostDirectories => Ok(None),
|
||||
Connection::Connected(provider) => Ok(Some(provider)),
|
||||
Connection::Lazy(lazy) => lazy
|
||||
.provider
|
||||
.get_or_try_init(|| async {
|
||||
connect_provider(&self.kind, &lazy.settings, &lazy.options)
|
||||
.await
|
||||
.map(|connected| owned(connected.provider))
|
||||
.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!("Failed to connect to the {} provider", self.kind),
|
||||
error,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
|
||||
let Some(provider) = self.provider().await? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let statuses = provider
|
||||
.list(&SandboxFilter::default())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::context(format!("Failed to list {} sandboxes", self.kind), error)
|
||||
})?;
|
||||
Ok(statuses
|
||||
.into_iter()
|
||||
.map(|status| SandboxInfo {
|
||||
provider: self.kind.clone(),
|
||||
status,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>> {
|
||||
let Some(provider) = self.provider().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// 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 provider.attach(&sandbox_id, None).await {
|
||||
Ok(handle) => handle,
|
||||
// Unknown to the provider, or not fabro's: neither is in the
|
||||
// inventory.
|
||||
Err(DriverError::NotFound { .. } | DriverError::NotOwned { .. }) => 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 == SandboxState::Deleted {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(SandboxInfo {
|
||||
provider: self.kind.clone(),
|
||||
status,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider narrowed to fabro's sandboxes.
|
||||
fn owned(provider: Arc<dyn DriverProvider>) -> Arc<dyn DriverProvider> {
|
||||
Arc::new(OwnedProvider::new(
|
||||
provider,
|
||||
managed_labels::ownership(None),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SandboxLookupError {
|
||||
#[error("sandbox '{id}' was not found by any configured provider")]
|
||||
NotFound { id: String },
|
||||
#[error("sandbox '{id}' matched more than one configured provider")]
|
||||
Conflict {
|
||||
id: String,
|
||||
providers: Vec<SandboxProviderKind>,
|
||||
},
|
||||
#[error("sandbox '{id}' could not be found definitively because one or more providers failed")]
|
||||
ProviderUnavailable {
|
||||
id: String,
|
||||
provider_errors: Vec<SandboxProviderLookupError>,
|
||||
},
|
||||
}
|
||||
|
||||
fn provider_error(
|
||||
provider: SandboxProviderKind,
|
||||
err: &(dyn std::error::Error + 'static),
|
||||
) -> SandboxProviderLookupError {
|
||||
SandboxProviderLookupError {
|
||||
provider,
|
||||
message: collect_chain(err).join(": "),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_types::settings::server::SandboxPluginSettings;
|
||||
use sandbox_driver::SandboxState;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::{
|
||||
ScriptedSandbox, managed_scripted_sandbox, scripted_inventory_provider,
|
||||
};
|
||||
|
||||
fn kind(name: &str) -> SandboxProviderKind {
|
||||
SandboxProviderKind::try_new(name).expect("valid kind")
|
||||
}
|
||||
|
||||
fn provider(kind: SandboxProviderKind, ids: &[&str]) -> ConnectedProvider {
|
||||
scripted_inventory_provider(
|
||||
kind,
|
||||
ids.iter().map(|id| managed_scripted_sandbox(id)).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// A plugin kind whose executable does not exist, so every lookup fails
|
||||
/// to connect.
|
||||
fn unreachable_plugin(inventory: SandboxInventory, name: &str) -> SandboxInventory {
|
||||
let settings = ServerSandboxProviderSettings {
|
||||
enabled: true,
|
||||
plugin: Some(SandboxPluginSettings {
|
||||
path: Some(format!("/nonexistent/fabro-sandbox-{name}")),
|
||||
dev: true,
|
||||
..SandboxPluginSettings::default()
|
||||
}),
|
||||
};
|
||||
inventory.with_lazy(kind(name), settings, ProviderConnectOptions::default())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_aggregates_fabro_owned_sandboxes_across_providers() {
|
||||
let foreign = Arc::new(
|
||||
ScriptedSandbox::with_id_and_working_dir("someone-elses", "/work")
|
||||
.state(SandboxState::Running),
|
||||
);
|
||||
let docker = scripted_inventory_provider(SandboxProviderKind::DOCKER, vec![
|
||||
managed_scripted_sandbox("docker-1"),
|
||||
foreign,
|
||||
]);
|
||||
let inventory = SandboxInventory::empty()
|
||||
.with_host_directories(SandboxProviderKind::LOCAL)
|
||||
.with_connected(docker)
|
||||
.with_connected(provider(SandboxProviderKind::DAYTONA, &["daytona-1"]));
|
||||
|
||||
let response = inventory.list_managed().await;
|
||||
|
||||
let mut ids: Vec<_> = response.data.iter().map(|s| s.status.id.as_str()).collect();
|
||||
ids.sort_unstable();
|
||||
assert_eq!(ids, ["daytona-1", "docker-1"]);
|
||||
assert!(response.meta.provider_errors.is_empty());
|
||||
let kinds: Vec<_> = inventory.kinds().cloned().collect();
|
||||
assert_eq!(kinds, [
|
||||
SandboxProviderKind::LOCAL,
|
||||
SandboxProviderKind::DOCKER,
|
||||
SandboxProviderKind::DAYTONA
|
||||
]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_reports_a_provider_that_cannot_connect_beside_the_others() {
|
||||
let inventory = unreachable_plugin(
|
||||
SandboxInventory::empty()
|
||||
.with_connected(provider(SandboxProviderKind::DOCKER, &["docker-1"])),
|
||||
"e2b",
|
||||
);
|
||||
|
||||
let response = inventory.list_managed().await;
|
||||
|
||||
assert_eq!(response.data.len(), 1);
|
||||
assert_eq!(response.meta.provider_errors.len(), 1);
|
||||
assert_eq!(response.meta.provider_errors[0].provider, kind("e2b"));
|
||||
assert!(
|
||||
response.meta.provider_errors[0]
|
||||
.message
|
||||
.contains("Failed to connect to the e2b provider"),
|
||||
"{}",
|
||||
response.meta.provider_errors[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_finds_one_sandbox_by_native_id() {
|
||||
let inventory = SandboxInventory::empty()
|
||||
.with_connected(provider(SandboxProviderKind::DOCKER, &[]))
|
||||
.with_connected(provider(SandboxProviderKind::DAYTONA, &["native-id"]));
|
||||
|
||||
let sandbox = inventory
|
||||
.get_managed_by_native_id("native-id")
|
||||
.await
|
||||
.expect("one provider matches");
|
||||
|
||||
assert_eq!(sandbox.status.id.as_str(), "native-id");
|
||||
assert_eq!(sandbox.provider, SandboxProviderKind::DAYTONA);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_reports_not_found_when_every_provider_misses() {
|
||||
let inventory = SandboxInventory::empty()
|
||||
.with_host_directories(SandboxProviderKind::LOCAL)
|
||||
.with_connected(provider(SandboxProviderKind::DOCKER, &[]));
|
||||
|
||||
let error = inventory
|
||||
.get_managed_by_native_id("missing")
|
||||
.await
|
||||
.expect_err("nothing matches");
|
||||
|
||||
assert!(matches!(error, SandboxLookupError::NotFound { id } if id == "missing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_reports_a_conflict_when_two_providers_match() {
|
||||
let inventory = SandboxInventory::empty()
|
||||
.with_connected(provider(SandboxProviderKind::DOCKER, &["same-id"]))
|
||||
.with_connected(provider(SandboxProviderKind::DAYTONA, &["same-id"]));
|
||||
|
||||
let error = inventory
|
||||
.get_managed_by_native_id("same-id")
|
||||
.await
|
||||
.expect_err("two providers match");
|
||||
|
||||
let SandboxLookupError::Conflict { providers, .. } = error else {
|
||||
panic!("expected a conflict, got {error:?}");
|
||||
};
|
||||
assert_eq!(providers, [
|
||||
SandboxProviderKind::DOCKER,
|
||||
SandboxProviderKind::DAYTONA
|
||||
]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_is_unavailable_when_no_match_and_a_provider_failed() {
|
||||
let inventory = unreachable_plugin(
|
||||
SandboxInventory::empty().with_connected(provider(SandboxProviderKind::DOCKER, &[])),
|
||||
"e2b",
|
||||
);
|
||||
|
||||
let error = inventory
|
||||
.get_managed_by_native_id("maybe-missing")
|
||||
.await
|
||||
.expect_err("the failed provider may have held it");
|
||||
|
||||
let SandboxLookupError::ProviderUnavailable {
|
||||
provider_errors, ..
|
||||
} = error
|
||||
else {
|
||||
panic!("expected provider unavailable, got {error:?}");
|
||||
};
|
||||
assert_eq!(provider_errors.len(), 1);
|
||||
assert_eq!(provider_errors[0].provider, kind("e2b"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_ignores_a_sandbox_without_the_managed_label() {
|
||||
let foreign = Arc::new(
|
||||
ScriptedSandbox::with_id_and_working_dir("foreign", "/work")
|
||||
.state(SandboxState::Running),
|
||||
);
|
||||
let inventory = SandboxInventory::empty().with_connected(scripted_inventory_provider(
|
||||
SandboxProviderKind::DOCKER,
|
||||
vec![foreign],
|
||||
));
|
||||
|
||||
let error = inventory
|
||||
.get_managed_by_native_id("foreign")
|
||||
.await
|
||||
.expect_err("a foreign sandbox is not in the inventory");
|
||||
|
||||
assert!(matches!(error, SandboxLookupError::NotFound { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
//! Run sandboxes on any provider fabro can name: a bundled kind in process
|
||||
//! or a sandbox-driver plugin executable.
|
||||
//!
|
||||
//! One path builds them all. The environment's spec arrives built (see
|
||||
//! [`crate::environment`]), the provider is connected through the single
|
||||
//! construction function, and a bundled provider adds only what its
|
||||
//! backend needs on top: Docker its fixed working directory and default
|
||||
//! image, Daytona its fixed working directory, default snapshot, and
|
||||
//! lifecycle timers, the Host the designated directory it works in,
|
||||
//! created when missing. A plugin gets the spec as is, trimmed to what it
|
||||
//! can honor, laid out inside the working directory the provider chooses.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fabro_types::{BundledProvider, RunId, SandboxProviderKind};
|
||||
use sandbox_driver::{
|
||||
EventContext, OwnedProvider, SandboxId, SandboxProvider, SandboxSource,
|
||||
SandboxSpec as DriverSpec,
|
||||
};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::driver::{ProviderAccess, connect_provider};
|
||||
use crate::driver_sandbox::{LayoutSource, RepoWorkspace, RunSandbox};
|
||||
use crate::environment::{self, CloneRequest};
|
||||
use crate::sandbox_spec::SandboxSpec;
|
||||
use crate::{daytona, docker, managed_labels};
|
||||
|
||||
/// A sandbox for a run on `kind`. The sandbox is created by `initialize`;
|
||||
/// construction validates the clone request and connects the provider, so
|
||||
/// a bad request, a missing credential, or a missing plugin executable
|
||||
/// fails before any backend call.
|
||||
pub async fn provider_sandbox(
|
||||
kind: SandboxProviderKind,
|
||||
access: &ProviderAccess,
|
||||
spec: DriverSpec,
|
||||
clone: &CloneRequest,
|
||||
run_id: Option<RunId>,
|
||||
) -> crate::Result<RunSandbox> {
|
||||
let workspace = RepoWorkspace::plan(layout_source(&kind), clone)?;
|
||||
let provider = connect(&kind, access, run_id.as_ref()).await?;
|
||||
let mut spec = spec;
|
||||
if let Some(run_id) = &run_id {
|
||||
spec = spec.name(environment::run_name(run_id));
|
||||
}
|
||||
Ok(match kind.bundled() {
|
||||
Some(BundledProvider::Docker) => {
|
||||
RunSandbox::pending(kind, provider, docker::overlay(spec), workspace)
|
||||
}
|
||||
Some(BundledProvider::Daytona) => RunSandbox::pending(
|
||||
kind,
|
||||
provider,
|
||||
daytona::overlay(spec, run_id.as_ref()),
|
||||
workspace,
|
||||
),
|
||||
Some(BundledProvider::Local) | None => {
|
||||
if kind.is_local() {
|
||||
designate_directory(&spec).await?;
|
||||
}
|
||||
let capabilities = provider.capabilities();
|
||||
spec.network = environment::supported_network(spec.network, capabilities);
|
||||
spec.timers = environment::supported_timers(spec.timers, capabilities);
|
||||
RunSandbox::pending(kind, provider, spec, workspace)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The Host provider works in a designated directory in place and needs it
|
||||
/// to exist. A run may point at a fresh scratch path, so the directory is
|
||||
/// created before the provider sees the spec.
|
||||
async fn designate_directory(spec: &DriverSpec) -> crate::Result<()> {
|
||||
let Some(directory) = &spec.working_directory else {
|
||||
return Ok(());
|
||||
};
|
||||
fs::create_dir_all(directory).await.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!("Failed to create working directory {directory}"),
|
||||
error,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// A sandbox on this host at `working_directory`, ready to use: the `local`
|
||||
/// kind, built through the provider path with default settings and
|
||||
/// initialized. For the agent CLI and tests; a run builds its sandbox from
|
||||
/// its [`SandboxSpec`] and initializes it itself.
|
||||
pub async fn local_sandbox(working_directory: impl Into<PathBuf>) -> crate::Result<RunSandbox> {
|
||||
let spec = SandboxSpec::local(working_directory, ProviderAccess::default());
|
||||
let sandbox = provider_sandbox(spec.kind, &spec.access, spec.spec, &spec.clone, None).await?;
|
||||
sandbox.initialize().await?;
|
||||
Ok(sandbox)
|
||||
}
|
||||
|
||||
/// Reattach to a run's sandbox on `kind` by its persisted id. The driver
|
||||
/// reports the sandbox's lifecycle from here on through `events`.
|
||||
///
|
||||
/// On a shared backend the sandbox must carry fabro's managed label and,
|
||||
/// when a run id is known, the matching run label: fabro never operates on
|
||||
/// a sandbox it did not create, and the ownership scope the provider is
|
||||
/// connected through refuses anything else. A local sandbox attaches by
|
||||
/// the id the Host provider derives from its directory.
|
||||
pub async fn attach_provider_sandbox(
|
||||
kind: SandboxProviderKind,
|
||||
access: &ProviderAccess,
|
||||
sandbox_id: &str,
|
||||
repo_cloned: bool,
|
||||
working_directory: String,
|
||||
clone_origin_url: Option<String>,
|
||||
run_id: Option<RunId>,
|
||||
events: Option<EventContext>,
|
||||
) -> crate::Result<RunSandbox> {
|
||||
let provider = connect(&kind, access, run_id.as_ref()).await?;
|
||||
let id = SandboxId::try_new(sandbox_id)
|
||||
.map_err(|error| crate::Error::context(format!("Invalid {kind} sandbox id"), error))?;
|
||||
let handle = match provider.attach(&id, events.clone()).await {
|
||||
Ok(handle) => handle,
|
||||
// A host sandbox is the directory it designates. An id the host
|
||||
// provider minted for a long path lives only in the registry of the
|
||||
// process that created it (a run's Petri worker, say), so a
|
||||
// reconnect from another process designates the directory again:
|
||||
// the same workspace, whatever the id.
|
||||
Err(error)
|
||||
if kind.bundled() == Some(BundledProvider::Local)
|
||||
&& matches!(error, sandbox_driver::Error::NotFound { .. }) =>
|
||||
{
|
||||
let spec = DriverSpec::new(SandboxSource::HostDirectory)
|
||||
.working_directory(working_directory.clone());
|
||||
provider.create(&spec, events).await.map_err(|error| {
|
||||
crate::Error::context(
|
||||
format!(
|
||||
"Failed to reconnect {kind} sandbox '{sandbox_id}' at {working_directory}"
|
||||
),
|
||||
error,
|
||||
)
|
||||
})?
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(crate::Error::context(
|
||||
format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"),
|
||||
error,
|
||||
));
|
||||
}
|
||||
};
|
||||
let status = handle.describe().await?;
|
||||
let workspace = RepoWorkspace::attached(
|
||||
layout_source(&kind),
|
||||
repo_cloned,
|
||||
working_directory,
|
||||
clone_origin_url,
|
||||
);
|
||||
let sandbox = RunSandbox::attached(kind, handle, workspace);
|
||||
if let Some(snapshot) = status.snapshot {
|
||||
sandbox.set_snapshot(snapshot);
|
||||
}
|
||||
Ok(sandbox)
|
||||
}
|
||||
|
||||
/// The image the run record names for a sandbox on `kind`: the
|
||||
/// environment's, or Docker's default when the environment names none.
|
||||
pub(crate) fn recorded_image(kind: &SandboxProviderKind, spec: &DriverSpec) -> Option<String> {
|
||||
match (kind.bundled(), &spec.source) {
|
||||
(Some(BundledProvider::Docker), _) => Some(docker::effective_image(spec)),
|
||||
(_, SandboxSource::Image { reference }) => Some(reference.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a run's repository checks out on `kind`: fabro fixes the roots
|
||||
/// inside the containers and VMs it shapes itself, and follows the working
|
||||
/// directory a plugin provider chooses.
|
||||
pub(crate) fn layout_source(kind: &SandboxProviderKind) -> LayoutSource {
|
||||
match kind.bundled() {
|
||||
Some(BundledProvider::Docker) => LayoutSource::Fixed(docker::layout()),
|
||||
Some(BundledProvider::Daytona) => LayoutSource::Fixed(daytona::layout()),
|
||||
Some(BundledProvider::Local) | None => LayoutSource::ProviderWorkingDirectory,
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-process Docker provider with default settings, for `fabro doctor`.
|
||||
pub(crate) async fn connect_bundled_docker(
|
||||
access: &ProviderAccess,
|
||||
) -> crate::Result<Arc<dyn SandboxProvider>> {
|
||||
connect(&SandboxProviderKind::DOCKER, access, None).await
|
||||
}
|
||||
|
||||
const MISSING_DAYTONA_CREDENTIALS: &str = "Daytona sandboxes require DAYTONA_API_KEY in the vault; run `fabro secret set DAYTONA_API_KEY`";
|
||||
|
||||
/// The provider for `kind`, scoped to the sandboxes fabro owns — narrowed to
|
||||
/// one run when `run_id` is known — so creates carry fabro's labels and
|
||||
/// attaches to anything else are refused.
|
||||
async fn connect(
|
||||
kind: &SandboxProviderKind,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<&RunId>,
|
||||
) -> crate::Result<Arc<dyn SandboxProvider>> {
|
||||
if kind.bundled() == Some(BundledProvider::Daytona) && access.daytona.is_none() {
|
||||
return Err(crate::Error::message(MISSING_DAYTONA_CREDENTIALS));
|
||||
}
|
||||
let settings = access.settings_for(kind).ok_or_else(|| {
|
||||
crate::Error::message(format!(
|
||||
"sandbox provider `{kind}` is not configured; add [server.sandbox.providers.{kind}] to settings.toml"
|
||||
))
|
||||
})?;
|
||||
let connected = connect_provider(kind, &settings, &access.connect_options())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::context(format!("Failed to connect to the {kind} provider"), error)
|
||||
})?;
|
||||
// A local sandbox is a directory the caller designated; it carries no
|
||||
// labels, and nothing else shares the host's directories with fabro.
|
||||
if kind.bundled() == Some(BundledProvider::Local) {
|
||||
return Ok(connected.provider);
|
||||
}
|
||||
Ok(Arc::new(OwnedProvider::new(
|
||||
connected.provider,
|
||||
managed_labels::ownership(run_id),
|
||||
)))
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
use anyhow::{Context, Result};
|
||||
use fabro_types::{RunId, RunSandboxInstance};
|
||||
use sandbox_driver::{EventContext, PtySession, PtySize};
|
||||
|
||||
use crate::driver::ProviderAccess;
|
||||
use crate::driver_sandbox::RunSandbox;
|
||||
use crate::provider_sandbox;
|
||||
|
||||
/// Reconnect to a run's sandbox from its saved record.
|
||||
///
|
||||
/// `access` carries the provider settings and vault credentials the record's
|
||||
/// provider needs; the process environment is never consulted. `run_id`
|
||||
/// narrows the ownership scope to the run when known, and the driver reports
|
||||
/// the sandbox's lifecycle from here on through `events`.
|
||||
pub async fn reconnect_for_run(
|
||||
record: &RunSandboxInstance,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<RunId>,
|
||||
events: Option<EventContext>,
|
||||
) -> Result<RunSandbox> {
|
||||
let runtime = &record.runtime;
|
||||
provider_sandbox::attach_provider_sandbox(
|
||||
record.provider.clone(),
|
||||
access,
|
||||
&runtime.id,
|
||||
// A record without the flag was written for a sandbox fabro never
|
||||
// cloned into.
|
||||
runtime.repo_cloned.unwrap_or(false),
|
||||
runtime.working_directory.clone(),
|
||||
runtime.clone_origin_url.clone(),
|
||||
run_id,
|
||||
events,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to reconnect {} sandbox", record.provider))
|
||||
}
|
||||
|
||||
/// Opens an interactive shell in a run's sandbox over the driver's Pty
|
||||
/// facet, reconnecting from the run record first. The session is the
|
||||
/// driver's own; it is closed by the caller.
|
||||
pub async fn open_terminal_for_run(
|
||||
record: &RunSandboxInstance,
|
||||
access: &ProviderAccess,
|
||||
run_id: Option<RunId>,
|
||||
size: PtySize,
|
||||
) -> crate::Result<Box<dyn PtySession>> {
|
||||
let sandbox = reconnect_for_run(record, access, run_id, None)
|
||||
.await
|
||||
.map_err(|err| crate::Error::context_anyhow("Failed to reconnect sandbox", err))?;
|
||||
sandbox.activate().await?;
|
||||
sandbox.open_terminal(size).await
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
//! Fabro's secret scanner on the text seams pebble exposes.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use pebble_coding_agent::extensions::Redactor;
|
||||
|
||||
/// Fabro's secret scanner as pebble's [`Redactor`].
|
||||
///
|
||||
/// Pebble calls it where text a process or the operating system wrote leaves
|
||||
/// a session: the output tail a shell tool puts on the event stream and the
|
||||
/// model-facing message of a failed tool call. It runs the same
|
||||
/// `fabro_redact::redact_string` pass the run's stored events go through, so
|
||||
/// what the model reads back matches what the log keeps. The final pass over
|
||||
/// every stored `RunEvent` stays in place: this one covers the text pebble
|
||||
/// hands the model and does not replace redaction of the stored event.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct SecretRedactor;
|
||||
|
||||
impl Redactor for SecretRedactor {
|
||||
fn redact<'a>(&self, text: &'a str) -> Cow<'a, str> {
|
||||
let redacted = fabro_redact::redact_string(text);
|
||||
if redacted == text {
|
||||
Cow::Borrowed(text)
|
||||
} else {
|
||||
Cow::Owned(redacted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_secret_redactor_borrows_clean_text_and_masks_secrets() {
|
||||
let redactor = SecretRedactor;
|
||||
assert!(matches!(
|
||||
redactor.redact("plain stderr"),
|
||||
Cow::Borrowed("plain stderr")
|
||||
));
|
||||
let redacted = redactor.redact("key=AKIAYRWQG5EJLPZLBYNP");
|
||||
assert!(matches!(redacted, Cow::Owned(_)));
|
||||
assert_eq!(redacted, "key=REDACTED");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
/// How much of each output stream a redacted tail keeps by default.
|
||||
pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Where a sandbox's workspace lives, as persisted on the run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SandboxWorkspaceLayout {
|
||||
pub workspace_root: String,
|
||||
pub repos_root: String,
|
||||
/// The repository checkout and its link in the workspace, when a
|
||||
/// repository was cloned.
|
||||
pub primary_repo_path: Option<String>,
|
||||
pub primary_repo_link: Option<String>,
|
||||
}
|
||||
|
||||
/// Build a redacted `ExecOutputTail` from stdout/stderr text without
|
||||
/// fabricating a synthetic `ExecResult`. Each stream is redacted, then
|
||||
/// capped to its newest `max_bytes_per_stream`. Terminal control sequences
|
||||
/// are not stripped here: command output reaches fabro with them already
|
||||
/// removed by the driver under [`crate::exec::SandboxExec`]'s output policy.
|
||||
/// Pass `""` for either stream that isn't relevant. Returns `None` when both
|
||||
/// streams are empty.
|
||||
#[must_use]
|
||||
pub fn redacted_output_tail(
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
max_bytes_per_stream: usize,
|
||||
) -> Option<fabro_types::ExecOutputTail> {
|
||||
let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream);
|
||||
let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream);
|
||||
let tail = fabro_types::ExecOutputTail {
|
||||
stdout,
|
||||
stderr,
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
};
|
||||
(!tail.is_empty()).then_some(tail)
|
||||
}
|
||||
|
||||
fn redacted_tail(text: &str, max_bytes: usize) -> (Option<String>, bool) {
|
||||
if text.is_empty() || max_bytes == 0 {
|
||||
return (None, !text.is_empty());
|
||||
}
|
||||
|
||||
let redacted = fabro_redact::redact_string(text);
|
||||
let truncated = redacted.len() > max_bytes;
|
||||
let start = if truncated {
|
||||
redacted.floor_char_boundary(redacted.len() - max_bytes)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let tail = redacted[start..].to_string();
|
||||
((!tail.is_empty()).then_some(tail), truncated)
|
||||
}
|
||||
|
||||
/// A regular file discovered inside a sandbox.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SandboxFile {
|
||||
/// Provider-resolved path accepted by sandbox filesystem operations.
|
||||
pub path: String,
|
||||
/// `/`-separated path relative to the requested traversal base.
|
||||
pub relative_path: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_path(path: &str, working_dir: &str) -> String {
|
||||
if std::path::Path::new(path).is_absolute() {
|
||||
path.to_string()
|
||||
} else {
|
||||
join_sandbox_path(working_dir, path)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn join_sandbox_path(base: &str, relative_path: &str) -> String {
|
||||
if relative_path.is_empty() {
|
||||
return base.to_string();
|
||||
}
|
||||
if base.is_empty() {
|
||||
return relative_path.to_string();
|
||||
}
|
||||
if base == "/" {
|
||||
return format!("/{relative_path}");
|
||||
}
|
||||
format!("{}/{relative_path}", base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn sandbox_tracing_events_do_not_log_raw_command_or_stdin_fields() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut failures = Vec::new();
|
||||
scan_for_command_tracing(&root, &mut failures);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"raw command/cmd/stdin tracing fields found:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "unit test performs a small synchronous source scan of local Rust files"
|
||||
)]
|
||||
fn scan_for_command_tracing(path: &std::path::Path, failures: &mut Vec<String>) {
|
||||
for entry in std::fs::read_dir(path).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_for_command_tracing(&path, failures);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let source = std::fs::read_to_string(&path).unwrap();
|
||||
for macro_name in [
|
||||
"tracing::trace!",
|
||||
"tracing::debug!",
|
||||
"tracing::info!",
|
||||
"tracing::warn!",
|
||||
"tracing::error!",
|
||||
"trace!",
|
||||
"debug!",
|
||||
"info!",
|
||||
"warn!",
|
||||
"error!",
|
||||
] {
|
||||
let mut rest = source.as_str();
|
||||
while let Some(idx) = rest.find(macro_name) {
|
||||
let start = source.len() - rest.len() + idx;
|
||||
if start > 0 && source.as_bytes()[start - 1] == b'"' {
|
||||
rest = &source[start + macro_name.len()..];
|
||||
continue;
|
||||
}
|
||||
let Some(call) = tracing_call(&source[start..]) else {
|
||||
break;
|
||||
};
|
||||
if call.contains("command,")
|
||||
|| call.contains("command =")
|
||||
|| call.contains("cmd,")
|
||||
|| call.contains("cmd =")
|
||||
|| call.contains("stdin,")
|
||||
|| call.contains("stdin =")
|
||||
{
|
||||
failures.push(format!(
|
||||
"{}: {}",
|
||||
path.display(),
|
||||
call.lines().next().unwrap_or(call)
|
||||
));
|
||||
}
|
||||
rest = &source[start + call.len()..];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tracing_call(source: &str) -> Option<&str> {
|
||||
let open = source.find('(')?;
|
||||
let mut depth = 0usize;
|
||||
for (idx, ch) in source.char_indices().skip(open) {
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Some(&source[..=idx]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
|
||||
use sandbox_driver::{EventContext, SandboxSource, SandboxSpec as DriverSpec};
|
||||
|
||||
use crate::driver::ProviderAccess;
|
||||
use crate::driver_sandbox::{LayoutSource, RunSandbox};
|
||||
use crate::environment::CloneRequest;
|
||||
use crate::{clone_source, provider_sandbox};
|
||||
|
||||
/// A run's sandbox on any provider fabro can name: a bundled kind in
|
||||
/// process or a sandbox-driver plugin. What the environment asked for, and
|
||||
/// the repository the run record names for it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SandboxSpec {
|
||||
pub kind: SandboxProviderKind,
|
||||
/// The provider settings and vault credentials the kind needs.
|
||||
pub access: ProviderAccess,
|
||||
/// The environment's request, as the driver spec every provider
|
||||
/// starts from.
|
||||
pub spec: DriverSpec,
|
||||
pub clone: CloneRequest,
|
||||
pub run_id: Option<RunId>,
|
||||
}
|
||||
|
||||
impl SandboxSpec {
|
||||
/// A sandbox on this host at `working_directory`, the fabro `local`
|
||||
/// kind. The directory is designated: the sandbox uses it in place,
|
||||
/// never removes it, and clones nothing into it. The Host provider has
|
||||
/// no image, labels, or lifecycle timers, so the spec names only the
|
||||
/// directory.
|
||||
#[must_use]
|
||||
pub fn local(working_directory: impl Into<PathBuf>, access: ProviderAccess) -> Self {
|
||||
Self {
|
||||
kind: SandboxProviderKind::LOCAL,
|
||||
access,
|
||||
spec: DriverSpec::new(SandboxSource::HostDirectory)
|
||||
.working_directory(working_directory.into().display().to_string()),
|
||||
clone: CloneRequest::none(),
|
||||
run_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider(&self) -> SandboxProviderKind {
|
||||
self.kind.clone()
|
||||
}
|
||||
|
||||
pub fn provider_name(&self) -> String {
|
||||
self.kind.to_string()
|
||||
}
|
||||
|
||||
/// The directory the spec designates on the provider, when it names one.
|
||||
#[must_use]
|
||||
pub fn working_directory(&self) -> Option<&str> {
|
||||
self.spec.working_directory.as_deref()
|
||||
}
|
||||
|
||||
/// The image the run record names for this sandbox: the environment's,
|
||||
/// or the provider's default when the environment names none.
|
||||
pub fn image(&self) -> Option<String> {
|
||||
provider_sandbox::recorded_image(&self.kind, &self.spec)
|
||||
}
|
||||
|
||||
/// Build initialized sandbox metadata for persistence.
|
||||
pub fn to_run_sandbox_instance(&self, sandbox: &RunSandbox) -> RunSandboxInstance {
|
||||
let working_directory = sandbox.working_directory().to_string();
|
||||
let id = sandbox.sandbox_info();
|
||||
let clone_origin_url = &self.clone.origin_url;
|
||||
let repo_cloned =
|
||||
clone_source::repo_cloned_for_record(self.clone.skip, clone_origin_url.as_deref());
|
||||
// A fixed layout is known before the sandbox exists; a
|
||||
// provider-chosen one only from the sandbox.
|
||||
let layout = match provider_sandbox::layout_source(&self.kind) {
|
||||
LayoutSource::Fixed(fixed) => {
|
||||
let repo = runtime_layout_metadata(
|
||||
repo_cloned,
|
||||
clone_origin_url.as_deref(),
|
||||
&fixed.workspace_root,
|
||||
&fixed.repos_root,
|
||||
);
|
||||
Some(crate::SandboxWorkspaceLayout {
|
||||
workspace_root: fixed.workspace_root,
|
||||
repos_root: fixed.repos_root,
|
||||
primary_repo_path: repo.as_ref().map(|layout| layout.primary_repo_path.clone()),
|
||||
primary_repo_link: repo.as_ref().map(|layout| layout.primary_repo_link.clone()),
|
||||
})
|
||||
}
|
||||
LayoutSource::ProviderWorkingDirectory => sandbox.workspace_layout(),
|
||||
};
|
||||
RunSandboxInstance {
|
||||
provider: self.kind.clone(),
|
||||
image: self.image(),
|
||||
snapshot: sandbox.snapshot_info(),
|
||||
runtime: RunSandboxRuntime {
|
||||
id,
|
||||
working_directory,
|
||||
repo_cloned,
|
||||
clone_origin_url: clone_source::clean_clone_origin_for_record(
|
||||
clone_origin_url.as_deref(),
|
||||
),
|
||||
clone_branch: self.clone.branch.clone(),
|
||||
workspace_root: layout.as_ref().map(|layout| layout.workspace_root.clone()),
|
||||
repos_root: layout.as_ref().map(|layout| layout.repos_root.clone()),
|
||||
primary_repo_path: layout
|
||||
.as_ref()
|
||||
.and_then(|layout| layout.primary_repo_path.clone()),
|
||||
primary_repo_link: layout
|
||||
.as_ref()
|
||||
.and_then(|layout| layout.primary_repo_link.clone()),
|
||||
},
|
||||
ready_duration_ms: None,
|
||||
retained: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the sandbox; `initialize` creates it on the provider. The
|
||||
/// driver reports its lifecycle through `events` from then on.
|
||||
pub async fn build(
|
||||
&self,
|
||||
events: Option<EventContext>,
|
||||
) -> Result<Arc<RunSandbox>, anyhow::Error> {
|
||||
let mut sandbox = provider_sandbox::provider_sandbox(
|
||||
self.kind.clone(),
|
||||
&self.access,
|
||||
self.spec.clone(),
|
||||
&self.clone,
|
||||
self.run_id,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create {} sandbox", self.kind))?;
|
||||
if let Some(events) = events {
|
||||
sandbox.set_events(events);
|
||||
}
|
||||
Ok(Arc::new(sandbox))
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_layout_metadata(
|
||||
repo_cloned: Option<bool>,
|
||||
clone_origin_url: Option<&str>,
|
||||
workspace_root: &str,
|
||||
repos_root: &str,
|
||||
) -> Option<clone_source::GitHubRepoLayout> {
|
||||
if repo_cloned != Some(true) {
|
||||
return None;
|
||||
}
|
||||
clone_source::github_repo_layout(clone_origin_url?, workspace_root, repos_root).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sandbox_driver_testing::ScriptedSandbox;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn docker_spec(clone: CloneRequest) -> SandboxSpec {
|
||||
SandboxSpec {
|
||||
kind: SandboxProviderKind::DOCKER,
|
||||
access: ProviderAccess::default(),
|
||||
spec: DriverSpec::new(SandboxSource::HostDirectory),
|
||||
clone,
|
||||
run_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox_at(kind: SandboxProviderKind, working_dir: &str) -> RunSandbox {
|
||||
RunSandbox::new(
|
||||
kind,
|
||||
Arc::new(ScriptedSandbox::with_id_and_working_dir(
|
||||
"scripted-1",
|
||||
working_dir,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() {
|
||||
let spec = docker_spec(CloneRequest {
|
||||
origin_url: Some("git@github.com:brynary/rack-test.git".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
..CloneRequest::default()
|
||||
});
|
||||
let sandbox = sandbox_at(SandboxProviderKind::DOCKER, "/workspace/rack-test");
|
||||
|
||||
let record = spec.to_run_sandbox_instance(&sandbox);
|
||||
let runtime = record.runtime;
|
||||
|
||||
assert_eq!(runtime.working_directory, "/workspace/rack-test");
|
||||
assert_eq!(runtime.repo_cloned, Some(true));
|
||||
assert_eq!(
|
||||
runtime.clone_origin_url.as_deref(),
|
||||
Some("https://github.com/brynary/rack-test")
|
||||
);
|
||||
assert_eq!(runtime.workspace_root.as_deref(), Some("/workspace"));
|
||||
assert_eq!(runtime.repos_root.as_deref(), Some("/repos"));
|
||||
assert_eq!(
|
||||
runtime.primary_repo_path.as_deref(),
|
||||
Some("/repos/brynary/rack-test")
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.primary_repo_link.as_deref(),
|
||||
Some("/workspace/rack-test")
|
||||
);
|
||||
let runtime_json = serde_json::to_value(&runtime).expect("runtime should serialize");
|
||||
assert!(runtime_json.get("clone_commit_sha").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_exact_checkout_spec_fails_before_provider_connection() {
|
||||
let spec = docker_spec(CloneRequest {
|
||||
origin_url: Some("https://github.com/acme/widgets".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
commit_sha: Some("not-a-sha".to_string()),
|
||||
..CloneRequest::default()
|
||||
});
|
||||
|
||||
let error = spec
|
||||
.build(None)
|
||||
.await
|
||||
.err()
|
||||
.expect("spec validation should run before Docker connection");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Failed to create docker sandbox")
|
||||
);
|
||||
assert!(format!("{error:#}").contains("40 ASCII hexadecimal"));
|
||||
assert!(!format!("{error:#}").contains("Docker daemon"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docker_run_sandbox_omits_primary_repo_metadata_for_empty_workspace() {
|
||||
let spec = docker_spec(CloneRequest {
|
||||
origin_url: Some("https://gitlab.com/acme/widgets".to_string()),
|
||||
..CloneRequest::none()
|
||||
});
|
||||
let sandbox = sandbox_at(SandboxProviderKind::DOCKER, "/workspace");
|
||||
|
||||
let record = spec.to_run_sandbox_instance(&sandbox);
|
||||
let runtime = record.runtime;
|
||||
|
||||
assert_eq!(runtime.working_directory, "/workspace");
|
||||
assert_eq!(runtime.repo_cloned, Some(false));
|
||||
assert_eq!(runtime.workspace_root.as_deref(), Some("/workspace"));
|
||||
assert_eq!(runtime.repos_root.as_deref(), Some("/repos"));
|
||||
assert!(runtime.primary_repo_path.is_none());
|
||||
assert!(runtime.primary_repo_link.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_spec_designates_the_directory_and_clones_nothing() {
|
||||
let spec = SandboxSpec::local("/home/dev/project", ProviderAccess::default());
|
||||
|
||||
assert_eq!(spec.kind, SandboxProviderKind::LOCAL);
|
||||
assert_eq!(spec.working_directory(), Some("/home/dev/project"));
|
||||
assert!(spec.clone.skip);
|
||||
assert_eq!(spec.clone.origin_url, None);
|
||||
assert_eq!(spec.image(), None);
|
||||
assert!(matches!(spec.spec.source, SandboxSource::HostDirectory));
|
||||
|
||||
let sandbox = sandbox_at(SandboxProviderKind::LOCAL, "/home/dev/project");
|
||||
let record = spec.to_run_sandbox_instance(&sandbox);
|
||||
|
||||
assert_eq!(record.provider, SandboxProviderKind::LOCAL);
|
||||
assert_eq!(record.image, None);
|
||||
assert_eq!(record.snapshot, None);
|
||||
assert_eq!(record.runtime.id, "scripted-1");
|
||||
assert_eq!(record.runtime.working_directory, "/home/dev/project");
|
||||
assert_eq!(record.runtime.repo_cloned, Some(false));
|
||||
assert_eq!(record.runtime.clone_origin_url, None);
|
||||
assert_eq!(record.runtime.clone_branch, None);
|
||||
assert_eq!(
|
||||
record.runtime.workspace_root.as_deref(),
|
||||
Some("/home/dev/project")
|
||||
);
|
||||
assert!(record.runtime.primary_repo_path.is_none());
|
||||
assert!(record.runtime.primary_repo_link.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,402 +0,0 @@
|
|||
//! Test doubles for fabro's sandbox layer.
|
||||
//!
|
||||
//! [`MockSandbox`] is a configuration over the sandbox driver's scripted
|
||||
//! double: a test writes down the files, the command answer, and the
|
||||
//! failures it wants, and takes a [`RunSandbox`] from it. What the code
|
||||
//! under test ran or wrote is read back from the driver double itself,
|
||||
//! through [`MockSandbox::driver`]; the few accessors here convert what a
|
||||
//! spec records into the shape fabro's tests assert on. Nothing here fakes
|
||||
//! fabro's own logic; every call goes through the real `RunSandbox` and
|
||||
//! fabro's exec policy, down to the scripted driver.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use sandbox_driver::{
|
||||
ExecResult, GrepMatch, PlatformInfo, SandboxState, StderrTail, Termination, WalkedFile,
|
||||
};
|
||||
use sandbox_driver_host::HostProvider;
|
||||
pub use sandbox_driver_testing::{
|
||||
ScriptedExec, ScriptedProvider, ScriptedSandbox, ScriptedStdioProcess,
|
||||
};
|
||||
use tokio::io::DuplexStream;
|
||||
|
||||
use crate::driver::ConnectedProvider;
|
||||
use crate::driver_sandbox::RunSandbox;
|
||||
use crate::managed_labels::{MANAGED_LABEL, MANAGED_LABEL_VALUE};
|
||||
use crate::sandbox::SandboxFile;
|
||||
|
||||
mod deleted_on_drop;
|
||||
|
||||
pub use deleted_on_drop::DeletedOnDrop;
|
||||
|
||||
/// The id a run record carries for a local sandbox at `working_directory`,
|
||||
/// as the Host provider derives it from the canonical path. A record a test
|
||||
/// writes by hand reconnects the way one fabro wrote would. The directory
|
||||
/// must exist.
|
||||
pub async fn local_sandbox_id(working_directory: &Path) -> String {
|
||||
HostProvider::directory_id(working_directory)
|
||||
.await
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"no local sandbox id for {}: the directory must exist",
|
||||
working_directory.display()
|
||||
)
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A driver [`ExecResult`] with the given streams, for scripting a mock
|
||||
/// sandbox's answers.
|
||||
#[must_use]
|
||||
pub fn exec_result(
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
exit_code: Option<i32>,
|
||||
termination: Termination,
|
||||
duration_ms: u64,
|
||||
) -> ExecResult {
|
||||
let mut result = ExecResult::new(termination, exit_code, Duration::from_millis(duration_ms));
|
||||
result.stdout = stdout.as_bytes().to_vec();
|
||||
result.stderr = stderr.as_bytes().to_vec();
|
||||
result
|
||||
}
|
||||
|
||||
// --- MockSandbox ---
|
||||
|
||||
/// What a test wants its sandbox to be, and what the code under test did
|
||||
/// with it.
|
||||
///
|
||||
/// Build it with a struct literal over [`MockSandbox::default`] (or
|
||||
/// [`MockSandbox::linux`]), then take the run sandbox with
|
||||
/// [`MockSandbox::sandbox`]. Every command answers with `exec_result`
|
||||
/// unless `exec_error` is set, in which case every command fails as a
|
||||
/// transport error. Files seed an in-memory filesystem under
|
||||
/// `working_dir`; absolute paths are kept as given.
|
||||
pub struct MockSandbox {
|
||||
pub files: HashMap<String, String>,
|
||||
pub exec_result: ExecResult,
|
||||
/// Fails every command before any process runs, so callers see a
|
||||
/// transport error rather than an `ExecResult`.
|
||||
pub exec_error: Option<String>,
|
||||
pub working_dir: &'static str,
|
||||
/// The run-scoped scratch directory the sandbox reports, outside any
|
||||
/// checkout; `None` models a provider without one.
|
||||
pub runtime_dir: Option<&'static str>,
|
||||
pub platform_str: &'static str,
|
||||
pub os_version_str: String,
|
||||
/// Fails `activate` after the sandbox is built, as a sandbox whose
|
||||
/// Bash contract broke would.
|
||||
pub activate_error: Option<String>,
|
||||
pub stdio_process: Option<MockStdioProcess>,
|
||||
pub stdio_process_error: Option<String>,
|
||||
/// Lines every grep returns, as `path:line:content`.
|
||||
pub grep_results: Vec<String>,
|
||||
/// Files returned by `walk_files` instead of the seeded files, before
|
||||
/// traversal-root and exclusion filtering.
|
||||
pub walk_files: Vec<SandboxFile>,
|
||||
pub walk_files_error: Option<String>,
|
||||
/// Reported by streaming execution. Set to `false` to model a provider
|
||||
/// that cannot separate stdout from stderr.
|
||||
pub streams_separated: bool,
|
||||
/// The sandbox once built. Public only so `..Default::default()` works
|
||||
/// from other crates; leave it at its default.
|
||||
pub built: OnceLock<Built>,
|
||||
}
|
||||
|
||||
/// The lazily built sandbox and its scripted driver.
|
||||
pub struct Built {
|
||||
run: Arc<RunSandbox>,
|
||||
driver: Arc<ScriptedSandbox>,
|
||||
}
|
||||
|
||||
impl Default for MockSandbox {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
files: HashMap::new(),
|
||||
exec_result: {
|
||||
let mut result =
|
||||
ExecResult::new(Termination::Exited, Some(0), Duration::from_millis(10));
|
||||
result.stdout = b"mock output".to_vec();
|
||||
result
|
||||
},
|
||||
exec_error: None,
|
||||
working_dir: "/work",
|
||||
runtime_dir: None,
|
||||
platform_str: "darwin",
|
||||
os_version_str: "Darwin 24.0.0".into(),
|
||||
activate_error: None,
|
||||
stdio_process: None,
|
||||
stdio_process_error: None,
|
||||
grep_results: Vec::new(),
|
||||
walk_files: Vec::new(),
|
||||
walk_files_error: None,
|
||||
streams_separated: true,
|
||||
built: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockSandbox {
|
||||
pub fn linux() -> Self {
|
||||
Self {
|
||||
working_dir: "/home/test",
|
||||
platform_str: "linux",
|
||||
os_version_str: "Linux 6.1.0".into(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_walk_files(mut self, files: Vec<SandboxFile>) -> Self {
|
||||
self.walk_files = files;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_walk_files_error(mut self, error: impl Into<String>) -> Self {
|
||||
self.walk_files_error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_activate_error(mut self, error: impl Into<String>) -> Self {
|
||||
self.activate_error = Some(error.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The run sandbox this configuration describes, built once: repeated
|
||||
/// calls return the same sandbox over the same recorder.
|
||||
pub fn sandbox(&self) -> Arc<RunSandbox> {
|
||||
Arc::clone(&self.built().run)
|
||||
}
|
||||
|
||||
/// The scripted driver double behind [`MockSandbox::sandbox`], for
|
||||
/// scripting beyond what the fields express.
|
||||
pub fn driver(&self) -> Arc<ScriptedSandbox> {
|
||||
Arc::clone(&self.built().driver)
|
||||
}
|
||||
|
||||
/// Answers commands by their Bash source, ahead of the queue and
|
||||
/// `exec_result`: a responder that returns `Some` decides the result,
|
||||
/// `None` falls through. For tests that interleave different commands
|
||||
/// and want each answered by what it is rather than by its position.
|
||||
pub fn respond_with(
|
||||
&self,
|
||||
responder: impl Fn(&str) -> Option<ExecResult> + Send + Sync + 'static,
|
||||
) -> &Self {
|
||||
self.driver().scripted_exec().respond_with(move |spec| {
|
||||
let command = spec.args.last().map(String::as_str).unwrap_or_default();
|
||||
responder(command)
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
fn built(&self) -> &Built {
|
||||
self.built.get_or_init(|| {
|
||||
let driver = Arc::new(self.build_driver());
|
||||
// The kind is nominal for exec: the explicit environment reaches
|
||||
// the scripted driver as the caller composed it on every provider.
|
||||
let run = RunSandbox::new_with_platform(
|
||||
SandboxProviderKind::DOCKER,
|
||||
Arc::clone(&driver) as Arc<dyn sandbox_driver::Sandbox>,
|
||||
self.platform_str,
|
||||
self.os_version_str.clone(),
|
||||
);
|
||||
Built {
|
||||
run: Arc::new(run),
|
||||
driver,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_driver(&self) -> ScriptedSandbox {
|
||||
let mut driver =
|
||||
ScriptedSandbox::with_id_and_working_dir("mock-sandbox", self.working_dir).platform(
|
||||
PlatformInfo::new(self.platform_str, "x86_64", self.os_version_str.clone()),
|
||||
);
|
||||
if let Some(directory) = self.runtime_dir {
|
||||
driver = driver.runtime_directory(directory);
|
||||
}
|
||||
if let Some(message) = &self.activate_error {
|
||||
// A stopped sandbox whose provider cannot start it.
|
||||
driver = driver
|
||||
.state(SandboxState::Stopped)
|
||||
.start_error(message.clone());
|
||||
}
|
||||
for (path, content) in &self.files {
|
||||
driver = driver.file(path, content);
|
||||
}
|
||||
let exec = driver.scripted_exec();
|
||||
match &self.exec_error {
|
||||
Some(message) => exec.fail_by_default(message.clone()),
|
||||
None => exec.set_default(self.exec_result.clone()),
|
||||
};
|
||||
exec.set_streams_separated(self.streams_separated);
|
||||
if let Some(message) = &self.stdio_process_error {
|
||||
exec.set_stdio_error(message.clone());
|
||||
}
|
||||
if let Some(process) = self.stdio_process.as_ref() {
|
||||
if let Some(scripted) = process.take() {
|
||||
exec.set_stdio_process(scripted);
|
||||
}
|
||||
}
|
||||
let search = driver.scripted_search();
|
||||
search.set_grep(
|
||||
self.grep_results
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let mut parts = line.splitn(3, ':');
|
||||
let path = parts.next().unwrap_or_default();
|
||||
let line_number = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
|
||||
GrepMatch::new(path, line_number, parts.next().unwrap_or_default())
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if let Some(message) = &self.walk_files_error {
|
||||
search.set_walk_error(message.clone());
|
||||
} else if !self.walk_files.is_empty() {
|
||||
search.set_walk(
|
||||
self.walk_files
|
||||
.iter()
|
||||
.map(|file| WalkedFile::new(file.relative_path.clone(), Some(file.size)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
driver
|
||||
}
|
||||
|
||||
fn recorded(&self) -> Vec<sandbox_driver::ExecSpec> {
|
||||
self.built
|
||||
.get()
|
||||
.map(|built| built.driver.scripted_exec().recorded())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The last command's Bash source. Every command, in order, is
|
||||
/// `driver().scripted_exec().commands()`.
|
||||
pub fn captured_command(&self) -> Option<String> {
|
||||
self.recorded()
|
||||
.last()
|
||||
.and_then(|spec| spec.args.last().cloned())
|
||||
}
|
||||
|
||||
/// The last command's timeout in milliseconds.
|
||||
pub fn captured_timeout(&self) -> Option<u64> {
|
||||
self.recorded()
|
||||
.last()
|
||||
.and_then(|spec| spec.timeout)
|
||||
.map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
/// The timeout of every command in milliseconds, in order.
|
||||
pub fn captured_timeouts(&self) -> Vec<u64> {
|
||||
self.recorded()
|
||||
.iter()
|
||||
.filter_map(|spec| spec.timeout)
|
||||
.map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The explicit variables of the last command as the caller passed them.
|
||||
/// The driver's Bash helper records its own `BASH_ENV` blank on the
|
||||
/// spec; that is not the caller's.
|
||||
pub fn captured_env_vars(&self) -> Option<HashMap<String, String>> {
|
||||
self.recorded().last().map(|spec| {
|
||||
spec.env
|
||||
.iter()
|
||||
.filter(|(key, _)| key.as_str() != sandbox_driver::BASH_ENV_VAR)
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Every file written so far as `(path, content)`, in order.
|
||||
pub fn written_files(&self) -> Vec<(String, String)> {
|
||||
self.built
|
||||
.get()
|
||||
.map(|built| {
|
||||
built
|
||||
.driver
|
||||
.memory_fs()
|
||||
.writes()
|
||||
.into_iter()
|
||||
.map(|(path, bytes)| (path, String::from_utf8_lossy(&bytes).into_owned()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// --- MockStdioProcess ---
|
||||
|
||||
/// A stdio process a test drives, over the driver's scripted process.
|
||||
///
|
||||
/// The driver closure receives the process's end of standard input, its
|
||||
/// end of standard output, and the rolling stderr tail the process reports.
|
||||
pub struct MockStdioProcess {
|
||||
inner: std::sync::Mutex<Option<ScriptedStdioProcess>>,
|
||||
}
|
||||
|
||||
impl MockStdioProcess {
|
||||
pub fn new(
|
||||
driver: impl FnOnce(DuplexStream, DuplexStream, StderrTail) + Send + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: std::sync::Mutex::new(Some(ScriptedStdioProcess::new(driver))),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_exit_code(self, exit_code: Option<i32>) -> Self {
|
||||
let inner = self.inner.lock().expect("stdio process").take();
|
||||
Self {
|
||||
inner: std::sync::Mutex::new(inner.map(|process| process.exit_code(exit_code))),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_wait_delay(self, wait_delay: Duration) -> Self {
|
||||
let inner = self.inner.lock().expect("stdio process").take();
|
||||
Self {
|
||||
inner: std::sync::Mutex::new(inner.map(|process| process.wait_delay(wait_delay))),
|
||||
}
|
||||
}
|
||||
|
||||
fn take(&self) -> Option<ScriptedStdioProcess> {
|
||||
self.inner.lock().expect("stdio process").take()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inventory doubles ---
|
||||
|
||||
/// A running scripted sandbox carrying fabro's managed label, so an owned
|
||||
/// inventory lists it and attaches to it.
|
||||
#[must_use]
|
||||
pub fn managed_scripted_sandbox(id: &str) -> Arc<ScriptedSandbox> {
|
||||
Arc::new(
|
||||
ScriptedSandbox::with_id_and_working_dir(id, "/work")
|
||||
.state(SandboxState::Running)
|
||||
.label(MANAGED_LABEL, MANAGED_LABEL_VALUE),
|
||||
)
|
||||
}
|
||||
|
||||
/// A connected inventory provider of `kind` holding `sandboxes`, over the
|
||||
/// driver's scripted provider.
|
||||
#[must_use]
|
||||
pub fn scripted_inventory_provider(
|
||||
kind: SandboxProviderKind,
|
||||
sandboxes: Vec<Arc<ScriptedSandbox>>,
|
||||
) -> ConnectedProvider {
|
||||
let provider = ScriptedProvider::new(kind.as_str());
|
||||
for sandbox in sandboxes {
|
||||
provider.register(sandbox);
|
||||
}
|
||||
ConnectedProvider {
|
||||
kind,
|
||||
provider: Arc::new(provider),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
//! A sandbox a test deletes even when it fails.
|
||||
//!
|
||||
//! A live test that creates a provider sandbox and deletes it on its last
|
||||
//! line leaks a running (and billed) sandbox whenever it panics or fails an
|
||||
//! assertion before that line. [`DeletedOnDrop`] owns the sandbox for the
|
||||
//! test: the happy path still calls `delete` explicitly, and any other exit
|
||||
//! deletes it from `Drop`.
|
||||
//!
|
||||
//! `Drop` is synchronous and may run while the test's runtime is unwinding
|
||||
//! a panic, so the cleanup never uses that runtime: it spawns a thread with
|
||||
//! a small runtime of its own and blocks until the delete finishes or a
|
||||
//! bounded timeout passes. A live provider's handle cannot be driven from
|
||||
//! that thread either, because its pooled HTTP connections are tasks on the
|
||||
//! test's runtime, which nobody polls while it unwinds. The guard therefore
|
||||
//! connects the provider afresh through the [`ProviderAccess`] the test
|
||||
//! built the sandbox with and deletes the sandbox by id over that new
|
||||
//! connection.
|
||||
|
||||
use std::fmt;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use tokio::runtime::Builder as RuntimeBuilder;
|
||||
use tokio::time;
|
||||
|
||||
use crate::driver::ProviderAccess;
|
||||
use crate::driver_sandbox::RunSandbox;
|
||||
use crate::error::display_for_log;
|
||||
use crate::provider_sandbox;
|
||||
|
||||
/// How long a drop-time delete may take before the guard gives up and
|
||||
/// reports the sandbox as possibly leaked. Daytona's driver bounds each
|
||||
/// delete call at 10s and may wait out a state change once; a reconnect
|
||||
/// adds a few seconds of its own.
|
||||
const DROP_DELETE_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
/// A run sandbox that is deleted when the guard drops, unless the test
|
||||
/// deleted it explicitly through [`DeletedOnDrop::delete`].
|
||||
///
|
||||
/// Derefs to the [`RunSandbox`] so a test reads the same as before; code
|
||||
/// that needs a shared handle takes one from [`DeletedOnDrop::shared`].
|
||||
pub struct DeletedOnDrop {
|
||||
sandbox: Arc<RunSandbox>,
|
||||
/// Access for a fresh provider connection at drop time. `None` deletes
|
||||
/// through the handle the sandbox already holds.
|
||||
access: Option<ProviderAccess>,
|
||||
deleted: bool,
|
||||
}
|
||||
|
||||
impl DeletedOnDrop {
|
||||
/// Guards a sandbox built through `access`, as every live provider test
|
||||
/// builds one. A drop-time delete reconnects the provider through
|
||||
/// `access` and deletes the sandbox by id.
|
||||
pub fn new(sandbox: impl Into<Arc<RunSandbox>>, access: &ProviderAccess) -> Self {
|
||||
Self {
|
||||
sandbox: sandbox.into(),
|
||||
access: Some(access.clone()),
|
||||
deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Guards a sandbox whose own handle can finish a delete from any
|
||||
/// thread: the scripted double, whose delete needs no live connection.
|
||||
/// Not for a live provider, whose handle is bound to the test's runtime
|
||||
/// (see the module docs).
|
||||
pub fn through_handle(sandbox: impl Into<Arc<RunSandbox>>) -> Self {
|
||||
Self {
|
||||
sandbox: sandbox.into(),
|
||||
access: None,
|
||||
deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared handle to the sandbox for code that takes an `Arc`, such as
|
||||
/// a workflow runner or an agent environment. The guard keeps its own
|
||||
/// and still deletes the sandbox when it drops.
|
||||
#[must_use]
|
||||
pub fn shared(&self) -> Arc<RunSandbox> {
|
||||
Arc::clone(&self.sandbox)
|
||||
}
|
||||
|
||||
/// Deletes the sandbox now, returning the driver's result. After a
|
||||
/// successful delete the drop does nothing; after a failed one it tries
|
||||
/// once more so a transient failure still leaves nothing behind.
|
||||
pub async fn delete(mut self) -> crate::Result<()> {
|
||||
let result = self.sandbox.delete().await;
|
||||
self.deleted = result.is_ok();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DeletedOnDrop {
|
||||
type Target = RunSandbox;
|
||||
|
||||
fn deref(&self) -> &RunSandbox {
|
||||
&self.sandbox
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DeletedOnDrop {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("DeletedOnDrop")
|
||||
.field("kind", self.sandbox.kind())
|
||||
.field("id", &self.sandbox.sandbox_info())
|
||||
.field("deleted", &self.deleted)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DeletedOnDrop {
|
||||
#[expect(
|
||||
clippy::print_stderr,
|
||||
reason = "The guard runs during a failing test; its report has to reach the captured test output."
|
||||
)]
|
||||
fn drop(&mut self) {
|
||||
if self.deleted {
|
||||
return;
|
||||
}
|
||||
let id = self.sandbox.sandbox_info();
|
||||
if id.is_empty() {
|
||||
// Never created on the provider: nothing to delete.
|
||||
return;
|
||||
}
|
||||
let kind = self.sandbox.kind().clone();
|
||||
eprintln!("DeletedOnDrop: deleting the {kind} sandbox {id} the test left behind");
|
||||
let outcome = delete_on_own_thread(
|
||||
kind.clone(),
|
||||
id.clone(),
|
||||
Arc::clone(&self.sandbox),
|
||||
self.access.clone(),
|
||||
);
|
||||
match outcome {
|
||||
Ok(()) => eprintln!("DeletedOnDrop: deleted the {kind} sandbox {id}"),
|
||||
Err(error) => {
|
||||
eprintln!("DeletedOnDrop: the {kind} sandbox {id} may be leaked: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the delete to completion on a dedicated thread with its own
|
||||
/// runtime, bounded by [`DROP_DELETE_TIMEOUT`].
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Drop is synchronous and the test's runtime may be unwinding; the delete needs a thread and runtime of its own."
|
||||
)]
|
||||
fn delete_on_own_thread(
|
||||
kind: SandboxProviderKind,
|
||||
id: String,
|
||||
sandbox: Arc<RunSandbox>,
|
||||
access: Option<ProviderAccess>,
|
||||
) -> Result<(), String> {
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("sandbox-delete-on-drop".to_string())
|
||||
.spawn(move || -> Result<(), String> {
|
||||
let runtime = RuntimeBuilder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| format!("could not build a runtime for the delete: {error}"))?;
|
||||
runtime.block_on(async {
|
||||
time::timeout(
|
||||
DROP_DELETE_TIMEOUT,
|
||||
delete_afresh(&kind, &id, &sandbox, access.as_ref()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"the delete did not finish within {}s",
|
||||
DROP_DELETE_TIMEOUT.as_secs()
|
||||
)
|
||||
})?
|
||||
})
|
||||
})
|
||||
.map_err(|error| format!("could not spawn the delete thread: {error}"))?;
|
||||
thread
|
||||
.join()
|
||||
.map_err(|_| "the delete thread panicked".to_string())?
|
||||
}
|
||||
|
||||
/// Deletes sandbox `id` over a fresh provider connection when `access` is
|
||||
/// given, through the sandbox's own handle otherwise.
|
||||
async fn delete_afresh(
|
||||
kind: &SandboxProviderKind,
|
||||
id: &str,
|
||||
sandbox: &RunSandbox,
|
||||
access: Option<&ProviderAccess>,
|
||||
) -> Result<(), String> {
|
||||
let Some(access) = access else {
|
||||
return sandbox
|
||||
.delete()
|
||||
.await
|
||||
.map_err(|error| display_for_log(&error));
|
||||
};
|
||||
let fresh = provider_sandbox::attach_provider_sandbox(
|
||||
kind.clone(),
|
||||
access,
|
||||
id,
|
||||
false,
|
||||
sandbox.working_directory().to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("could not reconnect: {}", display_for_log(&error)))?;
|
||||
fresh
|
||||
.delete()
|
||||
.await
|
||||
.map_err(|error| display_for_log(&error))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::MockSandbox;
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_once_when_dropped_without_an_explicit_delete() {
|
||||
let mock = MockSandbox::default();
|
||||
let guard = DeletedOnDrop::through_handle(mock.sandbox());
|
||||
assert_eq!(
|
||||
guard.working_directory(),
|
||||
"/work",
|
||||
"reads through to the sandbox"
|
||||
);
|
||||
assert_eq!(mock.driver().delete_count(), 0);
|
||||
|
||||
drop(guard);
|
||||
|
||||
assert_eq!(mock.driver().delete_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_explicit_delete_runs_once() {
|
||||
let mock = MockSandbox::default();
|
||||
let guard = DeletedOnDrop::through_handle(mock.sandbox());
|
||||
let shared = guard.shared();
|
||||
|
||||
guard.delete().await.unwrap();
|
||||
|
||||
assert_eq!(mock.driver().delete_count(), 1);
|
||||
drop(shared);
|
||||
assert_eq!(
|
||||
mock.driver().delete_count(),
|
||||
1,
|
||||
"a shared handle does not delete"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_before_the_delete_still_deletes_once() {
|
||||
let mock = MockSandbox::default();
|
||||
let sandbox = mock.sandbox();
|
||||
|
||||
let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
let _guard = DeletedOnDrop::through_handle(sandbox);
|
||||
panic!("the test failed before its delete");
|
||||
}));
|
||||
|
||||
assert!(outcome.is_err(), "the panic still propagates");
|
||||
assert_eq!(mock.driver().delete_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_panic_inside_a_runtime_still_deletes_once() {
|
||||
let mock = MockSandbox::default();
|
||||
let sandbox = mock.sandbox();
|
||||
|
||||
let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
let _guard = DeletedOnDrop::through_handle(sandbox);
|
||||
panic!("the test failed before its delete");
|
||||
}));
|
||||
|
||||
assert!(outcome.is_err(), "the panic still propagates");
|
||||
assert_eq!(mock.driver().delete_count(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,529 +0,0 @@
|
|||
mod daytona_streaming_live {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use fabro_sandbox::{
|
||||
CloneRequest, DaytonaCredentials, ExecControls, ExecSpec, ExecStreamingResult, OutputSink,
|
||||
OutputStream, ProviderAccess, RunSandbox, SandboxProviderKind, Termination,
|
||||
provider_sandbox,
|
||||
};
|
||||
use fabro_static::EnvVars;
|
||||
use sandbox_driver::{SandboxSource, SandboxSpec};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{Instant, sleep};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CapturedChunk {
|
||||
stream: OutputStream,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
||||
async fn daytona_streaming_live_smoke() -> Result<()> {
|
||||
ensure!(
|
||||
daytona_api_key_present(),
|
||||
"DAYTONA_API_KEY must be set to run this live smoke test"
|
||||
);
|
||||
|
||||
let sandbox = Arc::new(
|
||||
provider_sandbox(
|
||||
SandboxProviderKind::DAYTONA,
|
||||
&daytona_access(live_credentials()?),
|
||||
SandboxSpec::new(SandboxSource::HostDirectory),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
sandbox.initialize().await?;
|
||||
|
||||
let smoke_result = run_smoke(Arc::clone(&sandbox)).await;
|
||||
let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox");
|
||||
|
||||
smoke_result?;
|
||||
cleanup_result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Both command paths must reach the same interpreter, so Bash-only syntax
|
||||
/// that `sh` rejects has to behave identically through them. The two paths
|
||||
/// build different requests — a direct process exec and a toolbox session —
|
||||
/// so neither is evidence for the other.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
||||
async fn daytona_runs_bash_only_syntax_through_both_command_paths() -> Result<()> {
|
||||
ensure!(
|
||||
daytona_api_key_present(),
|
||||
"DAYTONA_API_KEY must be set to run this live smoke test"
|
||||
);
|
||||
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DAYTONA,
|
||||
&daytona_access(live_credentials()?),
|
||||
SandboxSpec::new(SandboxSource::HostDirectory),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
sandbox.initialize().await?;
|
||||
|
||||
// Arrays, `[[ ]]`, and `${arr[@]}` are Bash-only; `shopt -q
|
||||
// login_shell` proves neither path ran under a login shell.
|
||||
let command = "arr=(one two three); [[ ${#arr[@]} -eq 3 ]] || exit 1; \
|
||||
shopt -q login_shell && exit 2; echo ${arr[1]}";
|
||||
|
||||
let checks: Result<()> = async {
|
||||
let non_streaming = sandbox
|
||||
.exec_command(command, 30_000, None, None, None)
|
||||
.await?;
|
||||
ensure_eq(
|
||||
&non_streaming.exit_code,
|
||||
&Some(0),
|
||||
&format!("exec_command should run Bash-only syntax: {non_streaming:?}"),
|
||||
)?;
|
||||
ensure_contains(
|
||||
&non_streaming.stdout_lossy(),
|
||||
"two",
|
||||
"exec_command should report the Bash-only result",
|
||||
)?;
|
||||
|
||||
let (streaming, _) = run_captured(&sandbox, command, 30_000, None).await?;
|
||||
ensure_eq(
|
||||
&streaming.result.exit_code,
|
||||
&Some(0),
|
||||
&format!("exec_command_streaming should run Bash-only syntax: {streaming:?}"),
|
||||
)?;
|
||||
ensure_contains(
|
||||
&streaming.result.stdout_lossy(),
|
||||
"two",
|
||||
"exec_command_streaming should report the Bash-only result",
|
||||
)?;
|
||||
|
||||
let stdin = "first line\n$(touch /tmp/must-not-run)\nlast line";
|
||||
let (stdin_result, _) = run_captured_with_stdin(
|
||||
&sandbox,
|
||||
"cat",
|
||||
30_000,
|
||||
None,
|
||||
Some(stdin.as_bytes().to_vec()),
|
||||
)
|
||||
.await?;
|
||||
ensure_eq(
|
||||
&stdin_result.result.exit_code,
|
||||
&Some(0),
|
||||
"exec_command_streaming should close stdin with a successful EOF",
|
||||
)?;
|
||||
ensure_eq(
|
||||
&stdin_result.result.stdout,
|
||||
&stdin.as_bytes().to_vec(),
|
||||
"exec_command_streaming should preserve exact stdin bytes",
|
||||
)?;
|
||||
let stdin_cleanup = sandbox
|
||||
.exec_command(
|
||||
"test ! -e /tmp/must-not-run && \
|
||||
! compgen -G '/tmp/fabro-command-stdin-*' >/dev/null",
|
||||
30_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
ensure!(
|
||||
stdin_cleanup.success(),
|
||||
"Daytona stdin data must stay inert and its temporary file must be deleted: {stdin_cleanup:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox");
|
||||
|
||||
checks?;
|
||||
cleanup_result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
||||
async fn daytona_managed_labels_live_smoke() -> Result<()> {
|
||||
ensure!(
|
||||
daytona_api_key_present(),
|
||||
"DAYTONA_API_KEY must be set to run this live smoke test"
|
||||
);
|
||||
|
||||
// A fresh id per run: a fixed one would collide with a sandbox an
|
||||
// interrupted earlier run left behind.
|
||||
let run_id = fabro_types::RunId::new();
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DAYTONA,
|
||||
&daytona_access(live_credentials()?),
|
||||
SandboxSpec::new(SandboxSource::HostDirectory)
|
||||
.label("team".to_string(), "platform".to_string()),
|
||||
&CloneRequest::none(),
|
||||
Some(run_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
sandbox.initialize().await?;
|
||||
let labels = sandbox
|
||||
.handle()
|
||||
.context("sandbox handle should be initialized")?
|
||||
.describe()
|
||||
.await
|
||||
.context("describe sandbox")?
|
||||
.labels;
|
||||
let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox");
|
||||
|
||||
ensure_eq(
|
||||
&labels.get("sh.fabro.managed").map(String::as_str),
|
||||
&Some("true"),
|
||||
"Daytona should accept and return the managed label",
|
||||
)?;
|
||||
ensure_eq(
|
||||
&labels.get("sh.fabro.run_id").map(String::as_str),
|
||||
&Some(run_id.to_string().as_str()),
|
||||
"Daytona should accept and return the run id label",
|
||||
)?;
|
||||
ensure_eq(
|
||||
&labels.get("team").map(String::as_str),
|
||||
&Some("platform"),
|
||||
"Daytona should preserve user labels",
|
||||
)?;
|
||||
cleanup_result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Regression test for glob patterns that contain a path separator. Before
|
||||
// the glob fix, Daytona ran `find <base> -name <pattern>`, and `find -name`
|
||||
// matches only the basename and rejects patterns containing `/`. So
|
||||
// `*/SKILL.md` and `**/SKILL.md` silently returned an empty list even though
|
||||
// the files existed. Both `glob` calls below fail against that old
|
||||
// implementation and pass once traversal (the Daytona filesystem API) and
|
||||
// matching (host-side) are split.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
||||
async fn daytona_glob_matches_patterns_containing_a_path_separator() -> Result<()> {
|
||||
ensure!(
|
||||
daytona_api_key_present(),
|
||||
"DAYTONA_API_KEY must be set to run this live glob test"
|
||||
);
|
||||
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DAYTONA,
|
||||
&daytona_access(live_credentials()?),
|
||||
SandboxSpec::new(SandboxSource::HostDirectory),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
sandbox.initialize().await?;
|
||||
|
||||
let glob_result = run_glob_checks(&sandbox).await;
|
||||
let cleanup_result = sandbox.delete().await.context("clean up Daytona sandbox");
|
||||
|
||||
glob_result?;
|
||||
cleanup_result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_glob_checks(sandbox: &RunSandbox) -> Result<()> {
|
||||
// Build a skills tree with a SKILL.md at the search root, one level
|
||||
// below it, and two levels below it.
|
||||
let seed = sandbox
|
||||
.exec_command(
|
||||
"mkdir -p skills/patch skills/nested/deeper && \
|
||||
touch skills/SKILL.md skills/patch/SKILL.md skills/nested/deeper/SKILL.md",
|
||||
30_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
ensure!(
|
||||
seed.success(),
|
||||
"seeding the skills tree failed: stdout={} stderr={}",
|
||||
seed.stdout_lossy(),
|
||||
seed.stderr_lossy()
|
||||
);
|
||||
|
||||
// `*/SKILL.md` matches exactly one path segment: only the file one level
|
||||
// below the search directory, not the root file or the deeper one.
|
||||
let one_level = sandbox.glob("*/SKILL.md", Some("skills")).await?;
|
||||
ensure_eq(
|
||||
&one_level.len(),
|
||||
&1,
|
||||
"`*/SKILL.md` should match exactly one level below the search dir",
|
||||
)?;
|
||||
ensure!(
|
||||
one_level[0].ends_with("skills/patch/SKILL.md"),
|
||||
"`*/SKILL.md` should match the one-level-deep file, got {one_level:?}"
|
||||
);
|
||||
|
||||
// `**/SKILL.md` matches at any depth, including several levels down.
|
||||
let recursive = sandbox.glob("**/SKILL.md", Some("skills")).await?;
|
||||
ensure!(
|
||||
recursive
|
||||
.iter()
|
||||
.any(|path| path.ends_with("skills/nested/deeper/SKILL.md")),
|
||||
"`**/SKILL.md` should match files nested several levels deep, got {recursive:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_smoke(sandbox: Arc<RunSandbox>) -> Result<()> {
|
||||
let chunks = Arc::new(Mutex::new(Vec::new()));
|
||||
let cancel_token = CancellationToken::new();
|
||||
let callback = capture_callback(Arc::clone(&chunks));
|
||||
let sandbox_for_exec = Arc::clone(&sandbox);
|
||||
let cancel_for_exec = cancel_token.clone();
|
||||
|
||||
let live_exec = tokio::spawn(async move {
|
||||
sandbox_for_exec
|
||||
.exec_command_streaming(
|
||||
ExecSpec::bash("printf 'live-out\\n'; printf 'live-err\\n' >&2; sleep 30")
|
||||
.timeout(Duration::from_mins(1)),
|
||||
ExecControls {
|
||||
term: Some(cancel_for_exec),
|
||||
sink: Some(callback),
|
||||
..ExecControls::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let saw_live_stdout_and_stderr =
|
||||
wait_for_chunks(&chunks, Duration::from_secs(20), |chunks| {
|
||||
contains_chunk(chunks, OutputStream::Stdout, "live-out")
|
||||
&& contains_chunk(chunks, OutputStream::Stderr, "live-err")
|
||||
})
|
||||
.await;
|
||||
|
||||
cancel_token.cancel();
|
||||
|
||||
let live_result = live_exec
|
||||
.await
|
||||
.context("join live cancel command task")?
|
||||
.context("run live cancel command")?;
|
||||
|
||||
ensure!(
|
||||
saw_live_stdout_and_stderr,
|
||||
"expected live stdout and stderr chunks before cancellation, got {chunks:?}",
|
||||
chunks = chunks.lock().await
|
||||
);
|
||||
ensure!(
|
||||
live_result.live_streaming,
|
||||
"expected Daytona command logs to stream before completion"
|
||||
);
|
||||
ensure!(
|
||||
live_result.streams_separated,
|
||||
"expected Daytona command logs to separate stdout and stderr"
|
||||
);
|
||||
ensure_eq(
|
||||
&live_result.result.termination,
|
||||
&Termination::Cancelled,
|
||||
"cancelled command should preserve cancellation termination",
|
||||
)?;
|
||||
ensure_contains(
|
||||
&live_result.result.stdout_lossy(),
|
||||
"live-out",
|
||||
"cancelled command stdout should preserve partial logs",
|
||||
)?;
|
||||
ensure_contains(
|
||||
&live_result.result.stderr_lossy(),
|
||||
"live-err",
|
||||
"cancelled command stderr should preserve partial logs",
|
||||
)?;
|
||||
|
||||
let (nonzero, nonzero_chunks) = run_captured(
|
||||
sandbox.as_ref(),
|
||||
"printf 'exit-out\\n'; printf 'exit-err\\n' >&2; exit 7",
|
||||
30_000,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
ensure_eq(
|
||||
&nonzero.result.exit_code,
|
||||
&Some(7),
|
||||
"nonzero command should preserve the Daytona exit code",
|
||||
)?;
|
||||
ensure_eq(
|
||||
&nonzero.result.termination,
|
||||
&Termination::Exited,
|
||||
"nonzero command should be represented as a completed process",
|
||||
)?;
|
||||
ensure_contains(
|
||||
&nonzero.result.stdout_lossy(),
|
||||
"exit-out",
|
||||
"nonzero command stdout should be captured",
|
||||
)?;
|
||||
ensure_contains(
|
||||
&nonzero.result.stderr_lossy(),
|
||||
"exit-err",
|
||||
"nonzero command stderr should be captured",
|
||||
)?;
|
||||
ensure!(
|
||||
contains_chunk(&nonzero_chunks, OutputStream::Stdout, "exit-out"),
|
||||
"nonzero command should stream stdout chunks"
|
||||
);
|
||||
ensure!(
|
||||
contains_chunk(&nonzero_chunks, OutputStream::Stderr, "exit-err"),
|
||||
"nonzero command should stream stderr chunks"
|
||||
);
|
||||
|
||||
let (timed_out, _) = run_captured(
|
||||
sandbox.as_ref(),
|
||||
"printf 'timeout-out\\n'; printf 'timeout-err\\n' >&2; sleep 30",
|
||||
1_500,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
ensure_eq(
|
||||
&timed_out.result.termination,
|
||||
&Termination::TimedOut,
|
||||
"timed-out command should preserve timeout termination",
|
||||
)?;
|
||||
ensure_contains(
|
||||
&timed_out.result.stdout_lossy(),
|
||||
"timeout-out",
|
||||
"timed-out command stdout should preserve partial logs",
|
||||
)?;
|
||||
ensure_contains(
|
||||
&timed_out.result.stderr_lossy(),
|
||||
"timeout-err",
|
||||
"timed-out command stderr should preserve partial logs",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_captured(
|
||||
sandbox: &RunSandbox,
|
||||
command: &str,
|
||||
timeout_ms: u64,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> Result<(ExecStreamingResult, Vec<CapturedChunk>)> {
|
||||
run_captured_with_stdin(sandbox, command, timeout_ms, cancel_token, None).await
|
||||
}
|
||||
|
||||
async fn run_captured_with_stdin(
|
||||
sandbox: &RunSandbox,
|
||||
command: &str,
|
||||
timeout_ms: u64,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
stdin: Option<Vec<u8>>,
|
||||
) -> Result<(ExecStreamingResult, Vec<CapturedChunk>)> {
|
||||
let chunks = Arc::new(Mutex::new(Vec::new()));
|
||||
let callback = capture_callback(Arc::clone(&chunks));
|
||||
let mut spec = ExecSpec::bash(command).timeout(Duration::from_millis(timeout_ms));
|
||||
if let Some(stdin) = stdin {
|
||||
spec = spec.stdin(stdin);
|
||||
}
|
||||
let result = sandbox
|
||||
.exec_command_streaming(spec, ExecControls {
|
||||
term: cancel_token,
|
||||
sink: Some(callback),
|
||||
..ExecControls::default()
|
||||
})
|
||||
.await?;
|
||||
let chunks = chunks.lock().await.clone();
|
||||
|
||||
Ok((result, chunks))
|
||||
}
|
||||
|
||||
fn capture_callback(chunks: Arc<Mutex<Vec<CapturedChunk>>>) -> OutputSink {
|
||||
Arc::new(move |stream, bytes| {
|
||||
let chunks = Arc::clone(&chunks);
|
||||
Box::pin(async move {
|
||||
chunks.lock().await.push(CapturedChunk {
|
||||
stream,
|
||||
text: String::from_utf8_lossy(&bytes).into_owned(),
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "live smoke tests need a direct process-env preflight before provisioning Daytona"
|
||||
)]
|
||||
fn daytona_api_key_present() -> bool {
|
||||
std::env::var_os(EnvVars::DAYTONA_API_KEY).is_some()
|
||||
}
|
||||
|
||||
/// Live credentials from the process environment, the way the vault
|
||||
/// would supply them in production.
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "live smoke tests take Daytona credentials from the developer's environment"
|
||||
)]
|
||||
fn live_credentials() -> Result<DaytonaCredentials> {
|
||||
let api_key =
|
||||
std::env::var(EnvVars::DAYTONA_API_KEY).context("DAYTONA_API_KEY must be set")?;
|
||||
Ok(DaytonaCredentials::from_api_key(api_key, |name| {
|
||||
std::env::var(name).ok()
|
||||
}))
|
||||
}
|
||||
|
||||
fn daytona_access(credentials: DaytonaCredentials) -> ProviderAccess {
|
||||
ProviderAccess {
|
||||
daytona: Some(credentials),
|
||||
..ProviderAccess::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_chunks(
|
||||
chunks: &Arc<Mutex<Vec<CapturedChunk>>>,
|
||||
timeout_after: Duration,
|
||||
predicate: impl Fn(&[CapturedChunk]) -> bool,
|
||||
) -> bool {
|
||||
let deadline = Instant::now() + timeout_after;
|
||||
loop {
|
||||
if predicate(&chunks.lock().await) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_chunk(chunks: &[CapturedChunk], stream: OutputStream, text: &str) -> bool {
|
||||
chunks
|
||||
.iter()
|
||||
.any(|chunk| chunk.stream == stream && chunk.text.contains(text))
|
||||
}
|
||||
|
||||
fn ensure_contains(value: &str, needle: &str, message: &str) -> Result<()> {
|
||||
ensure!(
|
||||
value.contains(needle),
|
||||
"{message}: expected to find {needle:?} in {value:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_eq<T>(actual: &T, expected: &T, message: &str) -> Result<()>
|
||||
where
|
||||
T: std::fmt::Debug + PartialEq,
|
||||
{
|
||||
ensure!(
|
||||
actual == expected,
|
||||
"{message}: expected {expected:?}, got {actual:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,448 +0,0 @@
|
|||
//! Docker sandbox behaviour through the sandbox-driver Docker provider.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_sandbox::{
|
||||
CloneRequest, ExecControls, ExecSpec, OutputSink, ProviderAccess, SandboxProviderKind,
|
||||
Termination, provider_sandbox,
|
||||
};
|
||||
use sandbox_driver::{SandboxSource, SandboxSpec};
|
||||
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>>>) -> OutputSink {
|
||||
Arc::new(move |_stream, bytes| {
|
||||
let chunks = Arc::clone(&chunks);
|
||||
Box::pin(async move {
|
||||
chunks.lock().await.extend(bytes);
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[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";
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DOCKER,
|
||||
&ProviderAccess::default(),
|
||||
SandboxSpec::new(SandboxSource::Image {
|
||||
reference: image.to_string(),
|
||||
}),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
.await
|
||||
.expect("docker sandbox should initialize");
|
||||
|
||||
let chunks = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let marker = "fabro_streaming_timeout_sentinel";
|
||||
let command = format!("trap '' HUP TERM; echo start; sleep 5 # {marker}");
|
||||
let result = sandbox
|
||||
.exec_command_streaming(
|
||||
ExecSpec::bash(&command).timeout(Duration::from_millis(200)),
|
||||
ExecControls {
|
||||
sink: Some(capture_bytes(Arc::clone(&chunks))),
|
||||
..ExecControls::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("streaming command should return a timeout result");
|
||||
|
||||
assert_eq!(result.result.termination, Termination::TimedOut);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&chunks.lock().await).contains("start"),
|
||||
"stream should include output emitted before timeout"
|
||||
);
|
||||
|
||||
let probe = sandbox
|
||||
.exec_command(
|
||||
"marker='fabro_streaming_timeout_''sentinel'; \
|
||||
ps -eo pid,args | awk -v marker=\"$marker\" \
|
||||
'index($0, marker) && $0 !~ /awk/ && $0 !~ /ps -eo/ { print }'",
|
||||
1_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("process probe should run");
|
||||
sandbox
|
||||
.delete()
|
||||
.await
|
||||
.expect("docker cleanup should succeed");
|
||||
|
||||
let probe = probe.stdout_lossy();
|
||||
assert!(
|
||||
!probe.contains(marker),
|
||||
"timed-out docker exec should be terminated before returning, found: {probe}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[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";
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DOCKER,
|
||||
&ProviderAccess::default(),
|
||||
SandboxSpec::new(SandboxSource::Image {
|
||||
reference: image.to_string(),
|
||||
}),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
.await
|
||||
.expect("docker sandbox should initialize");
|
||||
|
||||
let stdin = b"first line\n$(touch /tmp/must-not-run)\nlast line".to_vec();
|
||||
let result = sandbox
|
||||
.exec_command_streaming(
|
||||
ExecSpec::bash("cat")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.stdin(stdin.clone()),
|
||||
ExecControls::default(),
|
||||
)
|
||||
.await
|
||||
.expect("streaming command should read stdin and finish at EOF");
|
||||
let injection_probe = sandbox
|
||||
.exec_command("test ! -e /tmp/must-not-run", 10_000, None, None, None)
|
||||
.await
|
||||
.expect("injection probe should run");
|
||||
|
||||
sandbox
|
||||
.delete()
|
||||
.await
|
||||
.expect("docker cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
result.result.success(),
|
||||
"stdin command failed: stdout={} stderr={}",
|
||||
result.result.stdout_lossy(),
|
||||
result.result.stderr_lossy()
|
||||
);
|
||||
assert_eq!(result.result.stdout, stdin);
|
||||
assert!(
|
||||
injection_probe.success(),
|
||||
"stdin bytes must not be evaluated as shell source"
|
||||
);
|
||||
}
|
||||
|
||||
// Both command paths must evaluate the same interpreter, so Bash-only syntax
|
||||
// that `sh` rejects has to behave identically through `exec_command` and
|
||||
// `exec_command_streaming`. Neither path is evidence for the other: they build
|
||||
// separate exec invocations, and the streaming one wraps the user command in a
|
||||
// controlled child.
|
||||
#[tokio::test]
|
||||
#[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";
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DOCKER,
|
||||
&ProviderAccess::default(),
|
||||
SandboxSpec::new(SandboxSource::Image {
|
||||
reference: image.to_string(),
|
||||
})
|
||||
.env_var("BASH_ENV".to_string(), "/tmp/fabro-bash-env".to_string()),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
.await
|
||||
.expect("docker sandbox should initialize");
|
||||
|
||||
// If the image-level BASH_ENV survives either exec boundary, every
|
||||
// subsequent Bash process prints this line before the requested command.
|
||||
let setup = sandbox
|
||||
.exec_command(
|
||||
"printf \"printf 'startup-source-loaded\\\\n'\\n\" > /tmp/fabro-bash-env",
|
||||
10_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("startup-file fixture should be created");
|
||||
assert!(setup.success());
|
||||
|
||||
// Arrays, `[[ ]]`, and `${arr[@]}` are Bash-only; `shopt -q login_shell`
|
||||
// proves the command did not run under a login shell. Exact output also
|
||||
// proves the image's BASH_ENV startup file was not sourced.
|
||||
let command = "arr=(one two three); [[ ${#arr[@]} -eq 3 ]] || exit 1; \
|
||||
shopt -q login_shell && exit 2; echo ${arr[1]}";
|
||||
|
||||
let non_streaming = sandbox
|
||||
.exec_command(command, 10_000, None, None, None)
|
||||
.await
|
||||
.expect("non-streaming command should run");
|
||||
|
||||
let chunks = Arc::new(Mutex::new(Vec::new()));
|
||||
let streaming = sandbox
|
||||
.exec_command_streaming(
|
||||
ExecSpec::bash(command).timeout(Duration::from_secs(10)),
|
||||
ExecControls {
|
||||
sink: Some(capture_bytes(Arc::clone(&chunks))),
|
||||
..ExecControls::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("streaming command should run");
|
||||
|
||||
sandbox
|
||||
.delete()
|
||||
.await
|
||||
.expect("docker cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
non_streaming.success(),
|
||||
"non-streaming Bash-only command failed: stdout={} stderr={}",
|
||||
non_streaming.stdout_lossy(),
|
||||
non_streaming.stderr_lossy()
|
||||
);
|
||||
assert_eq!(non_streaming.stdout_lossy().trim(), "two");
|
||||
assert!(
|
||||
streaming.result.success(),
|
||||
"streaming Bash-only command failed: stdout={} stderr={}",
|
||||
streaming.result.stdout_lossy(),
|
||||
streaming.result.stderr_lossy()
|
||||
);
|
||||
assert_eq!(streaming.result.stdout_lossy().trim(), "two");
|
||||
assert_eq!(String::from_utf8_lossy(&chunks.lock().await).trim(), "two");
|
||||
}
|
||||
|
||||
// Regression test for glob patterns that contain a path separator. Before the
|
||||
// glob fix, the remote providers ran `find <base> -name <pattern>`, and
|
||||
// `find -name` matches only the basename and rejects patterns containing `/`.
|
||||
// So `*/SKILL.md` and `**/SKILL.md` silently returned an empty list inside a
|
||||
// real container even though the files existed. Both `glob` calls below fail
|
||||
// against that old implementation and pass once traversal and matching are
|
||||
// split (find files, then match host-side).
|
||||
#[tokio::test]
|
||||
#[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";
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DOCKER,
|
||||
&ProviderAccess::default(),
|
||||
SandboxSpec::new(SandboxSource::Image {
|
||||
reference: image.to_string(),
|
||||
}),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
.await
|
||||
.expect("docker sandbox should initialize");
|
||||
|
||||
// Build a skills tree with a SKILL.md at the search root, one level below
|
||||
// it, and two levels below it.
|
||||
let seed = sandbox
|
||||
.exec_command(
|
||||
"mkdir -p skills/patch skills/nested/deeper && \
|
||||
touch skills/SKILL.md skills/patch/SKILL.md skills/nested/deeper/SKILL.md",
|
||||
10_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("seed command should run");
|
||||
|
||||
// `*/SKILL.md` matches exactly one path segment: only the file one level
|
||||
// below the search directory, not the root file or the deeper one.
|
||||
let one_level = sandbox.glob("*/SKILL.md", Some("skills")).await;
|
||||
// `**/SKILL.md` matches at any depth, including several levels down.
|
||||
let recursive = sandbox.glob("**/SKILL.md", Some("skills")).await;
|
||||
|
||||
sandbox
|
||||
.delete()
|
||||
.await
|
||||
.expect("docker cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
seed.success(),
|
||||
"seeding the skills tree failed: stdout={} stderr={}",
|
||||
seed.stdout_lossy(),
|
||||
seed.stderr_lossy()
|
||||
);
|
||||
|
||||
let one_level = one_level.expect("glob should run");
|
||||
assert_eq!(
|
||||
one_level.len(),
|
||||
1,
|
||||
"`*/SKILL.md` should match exactly one level below the search dir, got: {one_level:?}"
|
||||
);
|
||||
assert!(
|
||||
one_level[0].ends_with("skills/patch/SKILL.md"),
|
||||
"`*/SKILL.md` should match the one-level-deep file, got: {one_level:?}"
|
||||
);
|
||||
|
||||
let recursive = recursive.expect("recursive glob should run");
|
||||
assert!(
|
||||
recursive
|
||||
.iter()
|
||||
.any(|path| path.ends_with("skills/nested/deeper/SKILL.md")),
|
||||
"`**/SKILL.md` should match files nested several levels deep, got: {recursive:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The Fabro runtime directory is where prompt blobs materialize, so it must
|
||||
// exist after initialization, sit outside the repository checkout, and stay
|
||||
// owner-private along with the files written beneath it (issue #798).
|
||||
#[tokio::test]
|
||||
#[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";
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DOCKER,
|
||||
&ProviderAccess::default(),
|
||||
SandboxSpec::new(SandboxSource::Image {
|
||||
reference: image.to_string(),
|
||||
}),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
.await
|
||||
.expect("docker sandbox should initialize");
|
||||
|
||||
let runtime_directory = sandbox
|
||||
.runtime_directory()
|
||||
.expect("docker sandbox should expose a runtime directory")
|
||||
.to_string();
|
||||
assert!(
|
||||
!runtime_directory.starts_with(sandbox.working_directory()),
|
||||
"runtime directory {runtime_directory} must sit outside the workspace"
|
||||
);
|
||||
|
||||
let blob_path = format!("{runtime_directory}/blobs/test-blob.json");
|
||||
sandbox
|
||||
.write_file(&blob_path, "{}")
|
||||
.await
|
||||
.expect("runtime blob write should succeed");
|
||||
|
||||
let modes = sandbox
|
||||
.exec_command(
|
||||
&format!("stat -c '%a' {runtime_directory} {blob_path}"),
|
||||
10_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("stat should run");
|
||||
let readback = sandbox.read_file_text(&blob_path).await;
|
||||
|
||||
sandbox
|
||||
.delete()
|
||||
.await
|
||||
.expect("docker cleanup should succeed");
|
||||
|
||||
assert!(modes.success(), "stat failed: {}", modes.stderr_lossy());
|
||||
let modes = modes.stdout_lossy();
|
||||
let modes: Vec<&str> = modes.split_whitespace().collect();
|
||||
assert_eq!(
|
||||
modes,
|
||||
["700", "600"],
|
||||
"runtime directory and blob file should be owner-private"
|
||||
);
|
||||
assert_eq!(readback.expect("runtime blob should be readable"), "{}");
|
||||
}
|
||||
|
||||
/// The run sandbox over Docker is the `Environment` pebble's coding agent runs
|
||||
/// in for a Docker run, so it has to pass pebble's own contract there too:
|
||||
/// the Host proof in `environment.rs` covers the mapping, this covers the
|
||||
/// provider (derived search over `rg`/`grep`, `mv` for a move, a merged or
|
||||
/// separated stream pair).
|
||||
#[tokio::test]
|
||||
#[ignore = "requires real Docker container lifecycle; run explicitly when changing the pebble Environment mapping"]
|
||||
async fn docker_sandbox_satisfies_pebbles_environment_contract() {
|
||||
use pebble_coding_agent::test_support::EnvironmentContract;
|
||||
|
||||
let image = "buildpack-deps:noble";
|
||||
if !docker_image_available(image).await {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox = provider_sandbox(
|
||||
SandboxProviderKind::DOCKER,
|
||||
&ProviderAccess::default(),
|
||||
SandboxSpec::new(SandboxSource::Image {
|
||||
reference: image.to_string(),
|
||||
}),
|
||||
&CloneRequest::none(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("docker sandbox should construct");
|
||||
sandbox
|
||||
.initialize()
|
||||
.await
|
||||
.expect("docker sandbox should initialize");
|
||||
|
||||
let contract = EnvironmentContract::new(&sandbox, "pebble-contract")
|
||||
.with_operation_timeout(std::time::Duration::from_mins(1));
|
||||
let outcome = async {
|
||||
contract.verify_files().await?;
|
||||
contract.verify_search().await?;
|
||||
contract.verify_commands().await
|
||||
}
|
||||
.await;
|
||||
sandbox
|
||||
.delete()
|
||||
.await
|
||||
.expect("docker sandbox should clean up");
|
||||
outcome.expect("the Docker sandbox satisfies pebble's environment contract");
|
||||
}
|
||||
|
|
@ -1,405 +0,0 @@
|
|||
//! Phase 2 of the sandbox-driver adoption: measure agent tool-call latency
|
||||
//! through the driver against fabro's current providers before any cutover.
|
||||
//!
|
||||
//! 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 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
|
||||
//! protocol on an in-process duplex pipe, to size the budget for running a
|
||||
//! provider out of process later (the plan allows 100 ms per tool call).
|
||||
//!
|
||||
//! Ignored: it needs a Docker daemon with `buildpack-deps:noble` present and
|
||||
//! takes a minute. Run with
|
||||
//! `cargo nextest run -p fabro-sandbox --test driver_bench --run-ignored only
|
||||
//! --no-capture`.
|
||||
|
||||
#![allow(
|
||||
clippy::print_stderr,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
reason = "a benchmark reports through stderr and rounds durations for display"
|
||||
)]
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the fixture is packed and enumerated synchronously before the timed section starts"
|
||||
)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fabro_sandbox::{
|
||||
CloneRequest, ProviderAccess, RunSandbox, SandboxProviderKind, local_sandbox, provider_sandbox,
|
||||
};
|
||||
use sandbox_driver::{
|
||||
ExecSpec, GrepOptions, Sandbox as DriverHandle, SandboxProvider, SandboxSource, SandboxSpec,
|
||||
Search,
|
||||
};
|
||||
use sandbox_driver_docker::DockerProvider;
|
||||
use sandbox_driver_host::HostProvider;
|
||||
use sandbox_driver_protocol::{PluginProvider, serve};
|
||||
use tokio::io::{duplex, split};
|
||||
|
||||
const IMAGE: &str = "buildpack-deps:noble";
|
||||
const READS: usize = 200;
|
||||
const GREPS: usize = 20;
|
||||
const GREP_PATTERN: &str = "async fn ";
|
||||
|
||||
/// The medium repository: fabro's `lib/` tree, packed once per run.
|
||||
struct Repository {
|
||||
tarball: PathBuf,
|
||||
/// Repository-relative paths of the files the read benchmark samples.
|
||||
files: Vec<String>,
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
fn pack() -> Self {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.expect("workspace lib dir");
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let tarball = dir.path().join("repo.tar");
|
||||
let status = Command::new("tar")
|
||||
.args(["-cf"])
|
||||
.arg(&tarball)
|
||||
.args(["--exclude", "target", "--exclude", "node_modules", "-C"])
|
||||
.arg(&root)
|
||||
.arg(".")
|
||||
.status()
|
||||
.expect("tar available");
|
||||
assert!(status.success(), "packing the repository failed");
|
||||
let mut files: Vec<String> = walkdir(&root)
|
||||
.into_iter()
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "rs"))
|
||||
.filter_map(|path| {
|
||||
path.strip_prefix(&root)
|
||||
.ok()
|
||||
.map(|rel| rel.to_string_lossy().into_owned())
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
// A fixed stride samples the tree evenly and identically for every
|
||||
// provider under test.
|
||||
let stride = (files.len() / READS).max(1);
|
||||
let files = files.into_iter().step_by(stride).take(READS).collect();
|
||||
Self {
|
||||
tarball,
|
||||
files,
|
||||
_dir: dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walkdir(root: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if path.file_name().is_some_and(|name| name == "target") {
|
||||
continue;
|
||||
}
|
||||
stack.push(path);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Samples(Vec<Duration>);
|
||||
|
||||
impl Samples {
|
||||
fn record(&mut self, duration: Duration) {
|
||||
self.0.push(duration);
|
||||
}
|
||||
|
||||
fn percentile(&self, pct: f64) -> Duration {
|
||||
let mut sorted = self.0.clone();
|
||||
sorted.sort();
|
||||
if sorted.is_empty() {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let index = ((sorted.len() - 1) as f64 * pct).round() as usize;
|
||||
sorted[index]
|
||||
}
|
||||
|
||||
fn mean(&self) -> Duration {
|
||||
if self.0.is_empty() {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
self.0.iter().sum::<Duration>() / self.0.len() as u32
|
||||
}
|
||||
}
|
||||
|
||||
struct Row {
|
||||
label: &'static str,
|
||||
op: &'static str,
|
||||
n: usize,
|
||||
stats: Samples,
|
||||
}
|
||||
|
||||
fn report(rows: &[Row]) {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"{:<34} {:<8} {:>5} {:>9} {:>9} {:>9}",
|
||||
"provider", "op", "n", "p50 ms", "p95 ms", "mean ms"
|
||||
);
|
||||
for row in rows {
|
||||
eprintln!(
|
||||
"{:<34} {:<8} {:>5} {:>9.2} {:>9.2} {:>9.2}",
|
||||
row.label,
|
||||
row.op,
|
||||
row.n,
|
||||
row.stats.percentile(0.5).as_secs_f64() * 1000.0,
|
||||
row.stats.percentile(0.95).as_secs_f64() * 1000.0,
|
||||
row.stats.mean().as_secs_f64() * 1000.0,
|
||||
);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
/// The two operations an agent issues most: a file read and a content
|
||||
/// search, expressed against fabro's current trait.
|
||||
async fn bench_fabro(label: &'static str, sandbox: &RunSandbox, repo: &Repository) -> Vec<Row> {
|
||||
let mut reads = Samples::default();
|
||||
for file in &repo.files {
|
||||
let started = Instant::now();
|
||||
let bytes = sandbox
|
||||
.read_file_bytes(&format!("repo/{file}"))
|
||||
.await
|
||||
.expect("read");
|
||||
assert!(!bytes.is_empty());
|
||||
reads.record(started.elapsed());
|
||||
}
|
||||
let mut greps = Samples::default();
|
||||
let mut options = GrepOptions::default();
|
||||
options.include = Some("*.rs".to_owned());
|
||||
options.max_matches = Some(50);
|
||||
for _ in 0..GREPS {
|
||||
let started = Instant::now();
|
||||
let matches = sandbox
|
||||
.grep(GREP_PATTERN, "repo", &options)
|
||||
.await
|
||||
.expect("grep");
|
||||
assert!(!matches.is_empty());
|
||||
greps.record(started.elapsed());
|
||||
}
|
||||
vec![
|
||||
Row {
|
||||
label,
|
||||
op: "read",
|
||||
n: repo.files.len(),
|
||||
stats: reads,
|
||||
},
|
||||
Row {
|
||||
label,
|
||||
op: "grep",
|
||||
n: GREPS,
|
||||
stats: greps,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The same two operations against the driver's facets.
|
||||
async fn bench_driver(
|
||||
label: &'static str,
|
||||
sandbox: &dyn DriverHandle,
|
||||
repo: &Repository,
|
||||
) -> Vec<Row> {
|
||||
let mut reads = Samples::default();
|
||||
for file in &repo.files {
|
||||
let started = Instant::now();
|
||||
let bytes = sandbox
|
||||
.fs()
|
||||
.read(&format!("repo/{file}"))
|
||||
.await
|
||||
.expect("read");
|
||||
assert!(!bytes.is_empty());
|
||||
reads.record(started.elapsed());
|
||||
}
|
||||
let search = sandbox.search().expect("search facet");
|
||||
let mut options = GrepOptions::default();
|
||||
options.include = Some("*.rs".to_owned());
|
||||
options.max_matches = Some(50);
|
||||
let mut greps = Samples::default();
|
||||
for _ in 0..GREPS {
|
||||
let started = Instant::now();
|
||||
let matches = search
|
||||
.grep(GREP_PATTERN, "repo", &options)
|
||||
.await
|
||||
.expect("grep");
|
||||
assert!(!matches.is_empty());
|
||||
greps.record(started.elapsed());
|
||||
}
|
||||
vec![
|
||||
Row {
|
||||
label,
|
||||
op: "read",
|
||||
n: repo.files.len(),
|
||||
stats: reads,
|
||||
},
|
||||
Row {
|
||||
label,
|
||||
op: "grep",
|
||||
n: GREPS,
|
||||
stats: greps,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async fn unpack_fabro(sandbox: &RunSandbox, repo: &Repository) {
|
||||
sandbox
|
||||
.upload_file_from_local(&repo.tarball, "/tmp/repo.tar")
|
||||
.await
|
||||
.expect("upload");
|
||||
let result = sandbox
|
||||
.exec_command(
|
||||
"mkdir -p repo && tar -xf /tmp/repo.tar -C repo",
|
||||
120_000,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("unpack exec");
|
||||
assert!(result.success(), "unpack failed: {}", result.stderr_lossy());
|
||||
}
|
||||
|
||||
async fn unpack_driver(sandbox: &dyn DriverHandle, repo: &Repository) {
|
||||
sandbox
|
||||
.fs()
|
||||
.upload(&repo.tarball, "/tmp/repo.tar")
|
||||
.await
|
||||
.expect("upload");
|
||||
let result = sandbox
|
||||
.exec()
|
||||
.run(
|
||||
&ExecSpec::bash("mkdir -p repo && tar -xf /tmp/repo.tar -C repo")
|
||||
.timeout(Duration::from_mins(2)),
|
||||
)
|
||||
.await
|
||||
.expect("unpack exec");
|
||||
assert!(result.success(), "unpack failed: {}", result.stderr_lossy());
|
||||
}
|
||||
|
||||
fn docker_spec() -> SandboxSpec {
|
||||
SandboxSpec::new(SandboxSource::Image {
|
||||
reference: IMAGE.to_owned(),
|
||||
})
|
||||
.working_directory("/workspace")
|
||||
}
|
||||
|
||||
async fn serve_over_duplex(provider: Arc<dyn SandboxProvider>) -> PluginProvider {
|
||||
let (host_side, plugin_side) = duplex(1024 * 1024);
|
||||
let (host_read, host_write) = split(host_side);
|
||||
let (plugin_read, plugin_write) = split(plugin_side);
|
||||
tokio::spawn(serve(provider, plugin_read, plugin_write));
|
||||
PluginProvider::connect(host_read, host_write)
|
||||
.await
|
||||
.expect("handshake")
|
||||
}
|
||||
|
||||
#[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 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();
|
||||
let mut rows = Vec::new();
|
||||
|
||||
// -- Host, in-process: fabro local sandbox vs driver HostProvider.
|
||||
let host_dir = tempfile::tempdir().expect("tempdir");
|
||||
let local = local_sandbox(host_dir.path().to_path_buf())
|
||||
.await
|
||||
.expect("local sandbox should be created");
|
||||
local.initialize().await.expect("local init");
|
||||
unpack_fabro(&local, &repo).await;
|
||||
rows.extend(bench_fabro("fabro local sandbox", &local, &repo).await);
|
||||
|
||||
let host_provider = Arc::new(HostProvider::new());
|
||||
let host = host_provider
|
||||
.create(
|
||||
&SandboxSpec::new(SandboxSource::HostDirectory)
|
||||
.working_directory(host_dir.path().to_string_lossy().into_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("host create");
|
||||
rows.extend(bench_driver("driver Host (in-process)", host.as_ref(), &repo).await);
|
||||
|
||||
// -- Host over the wire (duplex pipe, no process boundary).
|
||||
let remote_host = serve_over_duplex(host_provider.clone()).await;
|
||||
let wire_host = remote_host.attach(host.id(), None).await.expect("attach");
|
||||
rows.extend(bench_driver("driver Host (JSON-RPC, duplex)", wire_host.as_ref(), &repo).await);
|
||||
drop(wire_host);
|
||||
remote_host.shutdown().await.expect("shutdown");
|
||||
host.delete().await.expect("host delete");
|
||||
|
||||
// -- Docker, in-process: fabro's driver-backed sandbox vs the bare driver.
|
||||
let fabro_docker = provider_sandbox(
|
||||
SandboxProviderKind::DOCKER,
|
||||
&ProviderAccess::default(),
|
||||
SandboxSpec::new(SandboxSource::Image {
|
||||
reference: IMAGE.to_owned(),
|
||||
}),
|
||||
&CloneRequest::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 Docker (driver-backed)", &fabro_docker, &repo).await);
|
||||
fabro_docker.delete().await.expect("fabro docker cleanup");
|
||||
|
||||
let docker_provider = Arc::new(DockerProvider::connect().await.expect("docker connect"));
|
||||
let container = docker_provider
|
||||
.create(&docker_spec(), None)
|
||||
.await
|
||||
.expect("driver docker create");
|
||||
unpack_driver(container.as_ref(), &repo).await;
|
||||
rows.extend(bench_driver("driver Docker (in-process)", container.as_ref(), &repo).await);
|
||||
|
||||
// -- Docker over the wire (duplex pipe, no process boundary).
|
||||
let remote_docker = serve_over_duplex(docker_provider.clone()).await;
|
||||
let wire_docker = remote_docker
|
||||
.attach(container.id(), None)
|
||||
.await
|
||||
.expect("attach");
|
||||
rows.extend(
|
||||
bench_driver(
|
||||
"driver Docker (JSON-RPC, duplex)",
|
||||
wire_docker.as_ref(),
|
||||
&repo,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
drop(wire_docker);
|
||||
remote_docker.shutdown().await.expect("shutdown");
|
||||
container.delete().await.expect("driver docker delete");
|
||||
|
||||
report(&rows);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
#[test]
|
||||
fn context_error_preserves_source_cause() {
|
||||
let source = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
|
||||
|
||||
let error = fabro_sandbox::Error::context("Failed to read file", source);
|
||||
|
||||
assert_eq!(error.to_string(), "Failed to read file");
|
||||
assert_eq!(error.causes(), vec!["permission denied"]);
|
||||
assert_eq!(
|
||||
error.display_with_causes(),
|
||||
"Failed to read file\n caused by: permission denied"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
//! The construction function serves a non-bundled kind through a plugin
|
||||
//! executable, and a sandbox created through one plugin generation is
|
||||
//! reachable by persisted id from a fresh connection.
|
||||
//!
|
||||
//! The executable is the driver's own `sandbox-driver-host`, found on `PATH`
|
||||
//! (CI installs it at the rev the workspace pins). Without it the tests skip,
|
||||
//! unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the test locates the plugin executable through the process PATH"
|
||||
)]
|
||||
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_sandbox::driver::{ProviderConnectOptions, connect_provider};
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings};
|
||||
use sandbox_driver::{ExecSpec, SandboxId, SandboxSource, SandboxSpec};
|
||||
|
||||
const HOST_PLUGIN: &str = "sandbox-driver-host";
|
||||
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
|
||||
/// The driver's Host executable on `PATH`, or `None` (after saying so) when
|
||||
/// the test should skip.
|
||||
fn host_plugin() -> Option<PathBuf> {
|
||||
let found = std::env::var_os("PATH").and_then(|path| {
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(HOST_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
if found.is_none() {
|
||||
assert!(
|
||||
std::env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set but {HOST_PLUGIN} is not on PATH"
|
||||
);
|
||||
eprintln!("skipping: {HOST_PLUGIN} is not on PATH");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn host_plugin_settings(executable: &Path, registry: &Path) -> ServerSandboxProviderSettings {
|
||||
ServerSandboxProviderSettings {
|
||||
enabled: true,
|
||||
plugin: Some(SandboxPluginSettings {
|
||||
path: Some(executable.display().to_string()),
|
||||
sha256: None,
|
||||
dev: true,
|
||||
args: Vec::new(),
|
||||
env: BTreeMap::from([(
|
||||
"SANDBOX_DRIVER_HOST_REGISTRY".to_string(),
|
||||
registry.display().to_string(),
|
||||
)]),
|
||||
inherit_env: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_plugin_under_a_non_bundled_kind_creates_and_reattaches_by_persisted_id() {
|
||||
let Some(executable) = host_plugin() else {
|
||||
return;
|
||||
};
|
||||
let registry = tempfile::tempdir().expect("registry tempdir");
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let kind = SandboxProviderKind::try_new("host").expect("host is a valid kind");
|
||||
assert_eq!(
|
||||
kind.bundled(),
|
||||
None,
|
||||
"host is not one of fabro's bundled kinds"
|
||||
);
|
||||
let settings = host_plugin_settings(&executable, registry.path());
|
||||
|
||||
let persisted_id: SandboxId = {
|
||||
let connected = connect_provider(&kind, &settings, &ProviderConnectOptions::default())
|
||||
.await
|
||||
.expect("plugin launches");
|
||||
assert_eq!(connected.kind, kind);
|
||||
assert_eq!(connected.provider.kind().as_str(), "host");
|
||||
let spec = SandboxSpec::new(SandboxSource::HostDirectory)
|
||||
.working_directory(workspace.path().display().to_string())
|
||||
.label("sh.fabro.managed", "true");
|
||||
let sandbox = connected
|
||||
.provider
|
||||
.create(&spec, None)
|
||||
.await
|
||||
.expect("create over the wire");
|
||||
let result = sandbox
|
||||
.exec()
|
||||
.run(&ExecSpec::bash(
|
||||
"printf hello > marker.txt && cat marker.txt",
|
||||
))
|
||||
.await
|
||||
.expect("exec over the wire");
|
||||
assert!(result.success(), "{result:?}");
|
||||
assert_eq!(result.stdout_lossy(), "hello");
|
||||
sandbox.id().clone()
|
||||
};
|
||||
|
||||
// A fresh connection is a new plugin process; the id alone must be
|
||||
// enough to find the sandbox again, exactly as run reconnect will do.
|
||||
let connected = connect_provider(&kind, &settings, &ProviderConnectOptions::default())
|
||||
.await
|
||||
.expect("plugin relaunches");
|
||||
let sandbox = connected
|
||||
.provider
|
||||
.attach(&persisted_id, None)
|
||||
.await
|
||||
.expect("attach by persisted id");
|
||||
let content = sandbox
|
||||
.fs()
|
||||
.read("marker.txt")
|
||||
.await
|
||||
.expect("file survives across plugin generations");
|
||||
assert_eq!(content, b"hello");
|
||||
assert!(workspace.path().join("marker.txt").is_file());
|
||||
sandbox.delete().await.expect("delete releases the handle");
|
||||
assert!(
|
||||
workspace.path().is_dir(),
|
||||
"designated directories are never removed by delete"
|
||||
);
|
||||
}
|
||||
|
||||
/// The configured kind is fabro's name for the executable it points at; the
|
||||
/// plugin's own declared kind is information, not a gate.
|
||||
#[tokio::test]
|
||||
async fn the_configured_kind_names_the_plugin_whatever_it_declares() {
|
||||
let Some(executable) = host_plugin() else {
|
||||
return;
|
||||
};
|
||||
let registry = tempfile::tempdir().expect("registry tempdir");
|
||||
let kind = SandboxProviderKind::try_new("host-alias").expect("valid kind");
|
||||
let connected = connect_provider(
|
||||
&kind,
|
||||
&host_plugin_settings(&executable, registry.path()),
|
||||
&ProviderConnectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("an aliased plugin launches");
|
||||
assert_eq!(connected.kind, kind);
|
||||
// Fabro's handle on the plugin carries the configured name, so records,
|
||||
// events, and errors all speak of the kind the operator wrote down.
|
||||
assert_eq!(connected.provider.kind().as_str(), "host-alias");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue