mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-03 02:24:33 +00:00
fix: address review of the in-process sandbox providers
- Keep plugin-era Daytona lease fingerprints: read only DAYTONA_API_URL and DAYTONA_ORGANIZATION_ID (no URL alias, no placement target), and stop forwarding DAYTONA_SERVER_URL and DAYTONA_TARGET to the worker. - Take the Docker fingerprint and network from this process's DOCKER_HOST, the endpoint the Docker client actually connects to; make the provider configuration's fields private. - Return an error instead of panicking when Petri supplies no Host registry. - Run deletion reads the Daytona key only for a Daytona run, and a forced or restarted delete goes on when the secret store fails, as it does for every other prune failure. - Stop putting DAYTONA_API_KEY in the worker's environment; the worker reads it from the vault. Give the worker's Daytona client the shared HTTP client. - Fork, rewind and retry no longer read the vault: a fork acquires no sandbox. - Remove the dead worker plugin forwarding and document that runs execute only on the built-in providers. - Build every Petri runtime through providers::standard_runtime or bare_runtime, with a Clippy lint against Runtime::standard/bare. - Share the Docker require-or-skip policy in fabro-test, tighten the Host scope assertion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8a09e8fa08
commit
a1a98c69d0
18 changed files with 205 additions and 432 deletions
|
|
@ -31,6 +31,8 @@ disallowed-methods = [
|
|||
{ path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true },
|
||||
{ path = "reqwest::blocking::Client::builder", reason = "Use fabro_http::BlockingHttpClientBuilder::new()", allow-invalid = true },
|
||||
{ path = "reqwest::get", reason = "Build a fabro_http client and send the request explicitly", allow-invalid = true },
|
||||
{ path = "runtime::Runtime::standard", reason = "Use fabro_petri::providers::standard_runtime, which installs the built-in in-process sandbox providers; without them Petri launches provider plugins, which a release build cannot verify", allow-invalid = true },
|
||||
{ path = "runtime::Runtime::bare", reason = "Use fabro_petri::providers::bare_runtime, which installs the built-in in-process sandbox providers; without them Petri launches provider plugins, which a release build cannot verify", allow-invalid = true },
|
||||
{ path = "fabro_types::settings::interp::InterpString::as_source", reason = "Returns the unresolved template source, which leaks {{ ... }} tokens as literal text downstream. Resolve via resolve()/resolve_with() or substitute via substitute_with() instead; document intentional raw-source access (serialization, error messages, deliberate source preservation) with #[expect(clippy::disallowed_methods, reason = \"...\")]", allow-invalid = true },
|
||||
]
|
||||
disallowed-types = [
|
||||
|
|
|
|||
|
|
@ -200,12 +200,9 @@ be lowercase ASCII letters, digits, and interior hyphens. The plugin starts with
|
|||
environment: only `env` and the ambient variables listed in `inherit_env` reach it. Bundled
|
||||
providers reject these plugin keys.
|
||||
|
||||
The same executable serves both sides of a run. The server launches it to reach a run's
|
||||
sandbox after the fact (the sandbox tab, files, terminal, Ask Fabro), and Petri launches it in
|
||||
the run's worker to create the sandbox. The server hands the worker `path` and `sha256` as
|
||||
`PETRI_SANDBOX_<KIND>_PLUGIN` and `PETRI_SANDBOX_<KIND>_SHA256` (the kind uppercased, hyphens
|
||||
as underscores), and `PETRI_SANDBOX_PLUGIN_DEV=1` when any configured plugin sets `dev`, so a
|
||||
plugin configured here needs no second configuration for the worker.
|
||||
The server launches the plugin to reach a sandbox after the fact (the sandbox tab, files,
|
||||
terminal, Ask Fabro). Runs execute only on the built-in providers for now, so a run's worker
|
||||
never launches a plugin and receives none of these settings.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[server.sandbox.providers.e2b]
|
||||
|
|
|
|||
|
|
@ -614,11 +614,12 @@ async fn runtime_spec(
|
|||
None
|
||||
}
|
||||
};
|
||||
let daytona = vault
|
||||
.read()
|
||||
.await
|
||||
.get(EnvVars::DAYTONA_API_KEY)
|
||||
.map(|key| DaytonaCredentials::from_api_key(key.to_owned(), crate::process_env_var));
|
||||
let daytona = vault.read().await.get(EnvVars::DAYTONA_API_KEY).map(|key| {
|
||||
// The same shared client the server attaches, so the worker's
|
||||
// Daytona calls take the server's proxy and CA policy.
|
||||
DaytonaCredentials::from_api_key(key.to_owned(), crate::process_env_var)
|
||||
.with_http_client(fabro_http::http_client().ok())
|
||||
});
|
||||
Ok(RuntimeSpec {
|
||||
sandbox: SandboxProviderConfig::from_lookup(daytona, crate::process_env_var),
|
||||
settings_toml: None,
|
||||
|
|
|
|||
|
|
@ -1730,8 +1730,12 @@ async fn a_delete_right_after_the_run_reads_ended_is_accepted() {
|
|||
server.shutdown();
|
||||
}
|
||||
|
||||
/// The release build must acquire and prune a real Host scope without any
|
||||
/// plugin executable or checksum. This also runs in CI's release profile.
|
||||
/// A Host run acquires and prunes a real scope with no plugin executable
|
||||
/// anywhere the worker or server would look. The server is also handed
|
||||
/// legacy Host plugin settings, which its prune must ignore; the worker
|
||||
/// never receives them (its environment allowlist drops them). The release
|
||||
/// workflow runs the suite in a release build, where no plugin checksum is
|
||||
/// pinned, so this is the check that a release can run a sandbox at all.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn built_in_host_runs_and_prunes_without_plugins() {
|
||||
let context = test_context!();
|
||||
|
|
@ -1764,12 +1768,14 @@ async fn built_in_host_runs_and_prunes_without_plugins() {
|
|||
wait_for_success(&server, &run_id).await;
|
||||
let run_dir = server.petri_run_dir(&run_id);
|
||||
let scopes = run_dir.join("scopes");
|
||||
let scope = std::fs::read_dir(&scopes)
|
||||
let entries = std::fs::read_dir(&scopes)
|
||||
.expect("the real Host scope exists")
|
||||
.next()
|
||||
.expect("one scope")
|
||||
.expect("the scope reads")
|
||||
.path();
|
||||
.map(|entry| entry.expect("the scope entry reads").path())
|
||||
.collect::<Vec<_>>();
|
||||
let [scope] = entries.as_slice() else {
|
||||
panic!("expected exactly one Host scope, found {entries:?}");
|
||||
};
|
||||
let scope = scope.clone();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(scope.join("work/built-in.txt"))
|
||||
.expect("the worker wrote its file"),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Fork, rewind, retry and the timeline over Petri runs, through a real
|
||||
//! server and its worker subprocess (the integration plan's F5.1).
|
||||
//! server and its worker subprocess.
|
||||
//!
|
||||
//! The harness is `petri.rs`'s: a foreground server on disk storage, a run
|
||||
//! created and started with `fabro run --detach`, executed by the worker
|
||||
|
|
|
|||
|
|
@ -7,17 +7,16 @@
|
|||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "test setup reads the process environment for its opt-in gate and probes Docker synchronously"
|
||||
reason = "a failed setup reads the isolated server's log synchronously"
|
||||
)]
|
||||
#![expect(
|
||||
clippy::print_stderr,
|
||||
reason = "a skipped scenario says why on the test's stderr"
|
||||
reason = "a failed setup prints the server log tail on the test's stderr"
|
||||
)]
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use fabro_test::{REQUIRE_SANDBOX_BACKENDS, TestContext, expect_reqwest_status};
|
||||
use fabro_test::{TestContext, expect_reqwest_status};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::support::server_endpoint;
|
||||
|
|
@ -30,13 +29,7 @@ pub(crate) const ENVIRONMENT: &str = "docker";
|
|||
/// [`DOCKER_IMAGE`]. Returns the environment id, or `None` when the
|
||||
/// prerequisites are missing and the test should skip.
|
||||
pub(crate) fn configure(context: &mut TestContext) -> Option<&'static str> {
|
||||
let required = std::env::var_os(REQUIRE_SANDBOX_BACKENDS).is_some();
|
||||
if !docker_image_available() {
|
||||
assert!(
|
||||
!required,
|
||||
"{REQUIRE_SANDBOX_BACKENDS} is set but no Docker daemon with {DOCKER_IMAGE} is available"
|
||||
);
|
||||
eprintln!("skipping: no Docker daemon with {DOCKER_IMAGE}");
|
||||
if !fabro_test::docker_image_available(DOCKER_IMAGE) {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -56,15 +49,6 @@ methods = ["dev-token"]
|
|||
Some(ENVIRONMENT)
|
||||
}
|
||||
|
||||
fn docker_image_available() -> bool {
|
||||
Command::new("docker")
|
||||
.args(["image", "inspect", DOCKER_IMAGE])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn toml_path(path: &Path) -> String {
|
||||
path.display().to_string().replace('\\', "/")
|
||||
}
|
||||
|
|
@ -106,7 +90,7 @@ fn create_environment(storage_dir: &Path) {
|
|||
}
|
||||
|
||||
/// Run a scenario; when it fails, print the isolated server's log first, since
|
||||
/// the worker's stderr (and so a plugin's launch failure) lands only there
|
||||
/// the worker's stderr (and so a sandbox provider failure) lands only there
|
||||
/// and the server root is removed when the context drops.
|
||||
pub(crate) fn run_with_server_log(context: &TestContext, scenario: impl FnOnce()) {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(scenario));
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ use crate::sandbox_access::{
|
|||
SandboxInventory,
|
||||
};
|
||||
use crate::server_secrets::ServerSecrets;
|
||||
use crate::spawn_env::{self, apply_render_graph_env};
|
||||
use crate::spawn_env::apply_render_graph_env;
|
||||
use crate::worker_control::{
|
||||
LocalWorkerControlBus, WORKER_CONTROL_ACK_WAIT, WorkerControlAcks, WorkerControlBus,
|
||||
WorkerControlBusError,
|
||||
|
|
@ -1558,22 +1558,18 @@ impl AppState {
|
|||
)
|
||||
}
|
||||
|
||||
/// [`Self::sandbox_provider_config`] with the Daytona key read from the
|
||||
/// vault, for server-side fork and prune; a secret store failure is a
|
||||
/// 500.
|
||||
/// [`Self::sandbox_provider_config`] for a server-side prune of
|
||||
/// `provider`'s sandboxes, with the Daytona key read from the vault only
|
||||
/// when `provider` is Daytona.
|
||||
pub(crate) async fn load_sandbox_provider_config(
|
||||
&self,
|
||||
) -> Result<SandboxProviderConfig, ApiError> {
|
||||
let daytona_api_key = self
|
||||
.vault_secret(EnvVars::DAYTONA_API_KEY)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(error = ?err, "Loading sandbox credentials failed");
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"secret store operation failed",
|
||||
)
|
||||
})?;
|
||||
provider: &SandboxProviderKind,
|
||||
) -> Result<SandboxProviderConfig, SecretStoreError> {
|
||||
let daytona_api_key = if *provider == SandboxProviderKind::DAYTONA {
|
||||
self.vault_secret(EnvVars::DAYTONA_API_KEY).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(self.sandbox_provider_config(daytona_api_key))
|
||||
}
|
||||
|
||||
|
|
@ -2907,7 +2903,27 @@ async fn delete_run_sandbox_resource(
|
|||
.run_scratch(&id)
|
||||
.root()
|
||||
.join("petri");
|
||||
let sandbox = state.load_sandbox_provider_config().await?;
|
||||
let sandbox = match state.load_sandbox_provider_config(&record.provider).await {
|
||||
Ok(sandbox) => sandbox,
|
||||
// A forced or restarted delete goes on without the sandboxes, as it
|
||||
// does for any other prune failure below.
|
||||
Err(err) if force || delete_started => {
|
||||
tracing::warn!(
|
||||
run_id = %id,
|
||||
provider = %record.provider,
|
||||
error = ?err,
|
||||
"Skipping the sandbox prune after loading sandbox credentials failed during run deletion"
|
||||
);
|
||||
return Ok(SandboxDeleteOutcome::Cleaned);
|
||||
}
|
||||
Err(err) => {
|
||||
error!(error = ?err, "Loading sandbox credentials failed");
|
||||
return Err(ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"secret store operation failed",
|
||||
));
|
||||
}
|
||||
};
|
||||
let report = prune::prune(PruneRequest {
|
||||
sandbox,
|
||||
run_id: id.to_string(),
|
||||
|
|
@ -3811,7 +3827,6 @@ fn worker_launch_spec(
|
|||
run_dir: &std::path::Path,
|
||||
agent_fabro_tools_enabled: bool,
|
||||
github_app_private_key: Option<String>,
|
||||
daytona_api_key: Option<String>,
|
||||
) -> anyhow::Result<WorkerLaunchSpec> {
|
||||
let current_exe = std::env::current_exe().context("reading current executable path")?;
|
||||
let executable =
|
||||
|
|
@ -3850,11 +3865,7 @@ fn worker_launch_spec(
|
|||
fabro_log,
|
||||
active_config_path: state.active_config_path().to_path_buf(),
|
||||
github_app_private_key,
|
||||
daytona_api_key,
|
||||
fabro_home: fabro_config::Home::from_env().root().to_path_buf(),
|
||||
sandbox_plugin_env: spawn_env::sandbox_plugin_env(
|
||||
&state.server_settings().server.sandbox.providers,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -4171,21 +4182,9 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
|
|||
return;
|
||||
}
|
||||
|
||||
// A Daytona run's worker hands the vault's key to Petri's Daytona
|
||||
// plugin through its own environment; any other run's worker never
|
||||
// sees it.
|
||||
let wants_daytona =
|
||||
run_state.spec.settings.run.environment.provider == SandboxProviderKind::DAYTONA;
|
||||
let secrets = async {
|
||||
let github_app_private_key = state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY).await?;
|
||||
let daytona_api_key = if wants_daytona {
|
||||
state.vault_secret(EnvVars::DAYTONA_API_KEY).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok::<_, SecretStoreError>((github_app_private_key, daytona_api_key))
|
||||
};
|
||||
let (github_app_private_key, daytona_api_key) = match secrets.await {
|
||||
// The worker reads the Daytona key from the vault itself; only the
|
||||
// GitHub App key crosses on its command.
|
||||
let github_app_private_key = match state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
fail_run_before_execution(
|
||||
|
|
@ -4209,7 +4208,6 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
|
|||
&run_dir_for_build,
|
||||
agent_fabro_tools_enabled,
|
||||
github_app_private_key,
|
||||
daytona_api_key,
|
||||
)
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -281,7 +281,6 @@ async fn fork_at(
|
|||
.await
|
||||
.map_err(|err| fork_error(&err))?;
|
||||
|
||||
let sandbox = state.load_sandbox_provider_config().await?;
|
||||
let new_run_id = RunId::new();
|
||||
let storage = Storage::new(state.server_storage_dir());
|
||||
let source_run_dir = storage.run_scratch(&id).root().to_path_buf();
|
||||
|
|
@ -300,7 +299,6 @@ async fn fork_at(
|
|||
.map_err(workflow_operation_error)?;
|
||||
|
||||
let seeded = petri_fork::fork(ForkRequest {
|
||||
sandbox,
|
||||
source: id,
|
||||
fork: new_run_id,
|
||||
source_run_dir: source_run_dir.join("petri"),
|
||||
|
|
|
|||
|
|
@ -2274,7 +2274,6 @@ fn worker_command_forwards_github_app_private_key_from_vault() {
|
|||
storage_dir.path(),
|
||||
false,
|
||||
Some("test-private-key".to_string()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cmd = LocalWorkerRuntime::command_for_spec(&spec);
|
||||
|
|
@ -2289,74 +2288,6 @@ fn worker_command_forwards_github_app_private_key_from_vault() {
|
|||
);
|
||||
}
|
||||
|
||||
/// A Daytona run's worker carries the vault's key for Petri's Daytona
|
||||
/// plugin.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn worker_command_forwards_daytona_api_key_from_vault() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let state = worker_command_test_state(storage_dir.path(), &["dev-token"], Some(TEST_DEV_TOKEN));
|
||||
let spec = worker_launch_spec(
|
||||
state.as_ref(),
|
||||
RunId::new(),
|
||||
RunExecutionMode::Start,
|
||||
storage_dir.path(),
|
||||
false,
|
||||
None,
|
||||
Some("dtn_test-key".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd = LocalWorkerRuntime::command_for_spec(&spec);
|
||||
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, EnvVars::DAYTONA_API_KEY),
|
||||
EnvOverride::Set("dtn_test-key".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// A plugin configured under `[server.sandbox.providers.<kind>]` reaches
|
||||
/// the worker under the names Petri reads, so a run on that kind finds its
|
||||
/// plugin without a second configuration.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn worker_command_forwards_configured_sandbox_plugins() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let state = worker_command_test_state_with_extra_config(
|
||||
storage_dir.path(),
|
||||
&["dev-token"],
|
||||
Some(TEST_DEV_TOKEN),
|
||||
r#"
|
||||
[server.sandbox.providers.e2b]
|
||||
path = "/opt/fabro/plugins/sandbox-driver-e2b"
|
||||
sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
dev = true
|
||||
"#,
|
||||
);
|
||||
let cmd = worker_command(
|
||||
state.as_ref(),
|
||||
RunId::new(),
|
||||
RunExecutionMode::Start,
|
||||
storage_dir.path(),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, "PETRI_SANDBOX_E2B_PLUGIN"),
|
||||
EnvOverride::Set("/opt/fabro/plugins/sandbox-driver-e2b".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, "PETRI_SANDBOX_E2B_SHA256"),
|
||||
EnvOverride::Set(
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, EnvVars::PETRI_SANDBOX_PLUGIN_DEV),
|
||||
EnvOverride::Set("1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn worker_command_omits_github_app_private_key_when_unset() {
|
||||
|
|
@ -2559,7 +2490,6 @@ fn worker_command(
|
|||
run_dir,
|
||||
agent_fabro_tools_enabled,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
Ok(LocalWorkerRuntime::command_for_spec(&spec))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use std::ffi::OsString;
|
||||
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::server::ServerSandboxProvidersSettings;
|
||||
use tokio::process::Command;
|
||||
|
||||
const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
||||
|
|
@ -51,9 +50,8 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|||
EnvVars::AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
|
||||
EnvVars::AWS_CONTAINER_CREDENTIALS_FULL_URI,
|
||||
EnvVars::AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE,
|
||||
// Preserve generic plugin configuration. Built-in providers run in process
|
||||
// and never receive executable paths or checksum overrides.
|
||||
EnvVars::PETRI_SANDBOX_PLUGIN_DEV,
|
||||
// Petri's sandbox settings the worker's in-process providers read. No
|
||||
// plugin settings cross: the worker never launches a provider plugin.
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_HOST_ADDRESS,
|
||||
EnvVars::PETRI_SANDBOX_ACTION_HOST_IMAGE,
|
||||
// The Docker daemon selection: the worker's Docker provider reads these
|
||||
|
|
@ -69,8 +67,6 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|||
// Daytona's non-secret selection. The worker reads the API key from
|
||||
// the vault and supplies it explicitly to the in-process provider.
|
||||
EnvVars::DAYTONA_API_URL,
|
||||
EnvVars::DAYTONA_SERVER_URL,
|
||||
EnvVars::DAYTONA_TARGET,
|
||||
EnvVars::DAYTONA_ORGANIZATION_ID,
|
||||
// A test's checkpoint gates: the worker's hooks hold at a named point
|
||||
// until the test releases them, so a crash can be placed there.
|
||||
|
|
@ -82,55 +78,9 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|||
|
||||
const RENDER_GRAPH_ENV_ALLOWLIST: &[&str] = &[EnvVars::PATH, EnvVars::HOME, EnvVars::TMPDIR];
|
||||
|
||||
/// The worker's environment: the allowlisted ambient variables, then the
|
||||
/// plugin variables the server's settings derive, which win over an
|
||||
/// ambient variable of the same name.
|
||||
pub(crate) fn apply_worker_env(cmd: &mut Command, sandbox_plugins: &[(String, String)]) {
|
||||
apply_worker_env_with(cmd, sandbox_plugins, &process_env_var_os);
|
||||
}
|
||||
|
||||
fn apply_worker_env_with(
|
||||
cmd: &mut Command,
|
||||
sandbox_plugins: &[(String, String)],
|
||||
lookup: &dyn Fn(&str) -> Option<OsString>,
|
||||
) {
|
||||
apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, lookup);
|
||||
for (name, value) in sandbox_plugins {
|
||||
cmd.env(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin variables Petri reads in the worker, derived from the
|
||||
/// server's `[server.sandbox.providers.<kind>]` settings: for every enabled
|
||||
/// third-party kind that carries plugin settings, `PETRI_SANDBOX_<KIND>_PLUGIN`
|
||||
/// from its `path` and `PETRI_SANDBOX_<KIND>_SHA256` from its `sha256`, and
|
||||
/// `PETRI_SANDBOX_PLUGIN_DEV=1` when any of them sets `dev`. The kind is
|
||||
/// uppercased with hyphens as underscores, as Petri names the variable. A
|
||||
/// kind whose settings name no path is left to Petri's own lookup
|
||||
/// (`sandbox-driver-<kind>` beside the executable, then on `PATH`), the
|
||||
/// same lookup the server's attach uses.
|
||||
pub(crate) fn sandbox_plugin_env(
|
||||
providers: &ServerSandboxProvidersSettings,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut env = Vec::new();
|
||||
let mut dev = false;
|
||||
for (kind, plugin) in providers.enabled_plugins() {
|
||||
let upper = kind.as_str().to_ascii_uppercase().replace('-', "_");
|
||||
if let Some(path) = &plugin.path {
|
||||
env.push((format!("PETRI_SANDBOX_{upper}_PLUGIN"), path.clone()));
|
||||
}
|
||||
if let Some(sha256) = &plugin.sha256 {
|
||||
env.push((format!("PETRI_SANDBOX_{upper}_SHA256"), sha256.clone()));
|
||||
}
|
||||
dev |= plugin.dev;
|
||||
}
|
||||
if dev {
|
||||
env.push((
|
||||
EnvVars::PETRI_SANDBOX_PLUGIN_DEV.to_string(),
|
||||
"1".to_string(),
|
||||
));
|
||||
}
|
||||
env
|
||||
/// The worker's environment: the allowlisted ambient variables only.
|
||||
pub(crate) fn apply_worker_env(cmd: &mut Command) {
|
||||
apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, &process_env_var_os);
|
||||
}
|
||||
|
||||
pub(crate) fn apply_render_graph_env(cmd: &mut Command) {
|
||||
|
|
@ -160,15 +110,7 @@ mod tests {
|
|||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_types::settings::server::{
|
||||
SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings,
|
||||
};
|
||||
|
||||
use super::{
|
||||
RENDER_GRAPH_ENV_ALLOWLIST, WORKER_ENV_ALLOWLIST, apply_allowlist, apply_worker_env_with,
|
||||
sandbox_plugin_env,
|
||||
};
|
||||
use super::{RENDER_GRAPH_ENV_ALLOWLIST, WORKER_ENV_ALLOWLIST, apply_allowlist};
|
||||
|
||||
fn env_command() -> tokio::process::Command {
|
||||
assert!(Path::new("/usr/bin/env").exists());
|
||||
|
|
@ -292,16 +234,10 @@ mod tests {
|
|||
Some("xterm-256color")
|
||||
);
|
||||
assert_eq!(actual.get("NO_COLOR").map(String::as_str), Some("1"));
|
||||
// Built-in plugin paths and pins never reach the worker.
|
||||
for kind in ["HOST", "DOCKER", "DAYTONA"] {
|
||||
for suffix in ["PLUGIN", "SHA256"] {
|
||||
assert!(!actual.contains_key(&format!("PETRI_SANDBOX_{kind}_{suffix}")));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_PLUGIN_DEV").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
// No plugin setting reaches the worker: it never launches a
|
||||
// provider plugin.
|
||||
assert!(!actual.contains_key("PETRI_SANDBOX_HOST_PLUGIN"));
|
||||
assert!(!actual.contains_key("PETRI_SANDBOX_PLUGIN_DEV"));
|
||||
// The Docker daemon selection crosses whole, so the worker's Docker
|
||||
// provider drives the daemon the server uses.
|
||||
assert_eq!(
|
||||
|
|
@ -338,11 +274,11 @@ mod tests {
|
|||
actual.get("DAYTONA_ORGANIZATION_ID").map(String::as_str),
|
||||
Some("org-1")
|
||||
);
|
||||
assert_eq!(
|
||||
actual.get("DAYTONA_SERVER_URL").map(String::as_str),
|
||||
Some("https://daytona-alias.internal/api")
|
||||
);
|
||||
assert_eq!(actual.get("DAYTONA_TARGET").map(String::as_str), Some("us"));
|
||||
// The URL alias and placement target never reached the Daytona
|
||||
// plugin, so they stay out and plugin-era leases keep their
|
||||
// fingerprint.
|
||||
assert!(!actual.contains_key("DAYTONA_SERVER_URL"));
|
||||
assert!(!actual.contains_key("DAYTONA_TARGET"));
|
||||
assert!(!actual.contains_key("DAYTONA_API_KEY"));
|
||||
assert_eq!(actual.get("CLICOLOR").map(String::as_str), Some("0"));
|
||||
assert_eq!(actual.get("CLICOLOR_FORCE").map(String::as_str), Some("1"));
|
||||
|
|
@ -382,99 +318,6 @@ mod tests {
|
|||
assert!(!actual.contains_key("MY_API_KEY"));
|
||||
}
|
||||
|
||||
fn provider(
|
||||
kind: &str,
|
||||
enabled: bool,
|
||||
plugin: SandboxPluginSettings,
|
||||
) -> (SandboxProviderKind, ServerSandboxProviderSettings) {
|
||||
(
|
||||
SandboxProviderKind::try_new(kind).expect("a valid kind"),
|
||||
ServerSandboxProviderSettings {
|
||||
enabled,
|
||||
plugin: Some(plugin),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A configured plugin reaches the worker under the names Petri reads,
|
||||
/// while built-in paths and pins and disabled third-party plugins never
|
||||
/// cross.
|
||||
#[tokio::test]
|
||||
async fn third_party_plugins_reach_the_worker_and_built_ins_never_do() {
|
||||
let mut providers = ServerSandboxProvidersSettings::default();
|
||||
providers.entries.extend([
|
||||
provider("e2b", true, SandboxPluginSettings {
|
||||
path: Some("/opt/fabro/plugins/sandbox-driver-e2b".to_string()),
|
||||
sha256: Some("0123abcd".to_string()),
|
||||
dev: true,
|
||||
..SandboxPluginSettings::default()
|
||||
}),
|
||||
provider("docker", true, SandboxPluginSettings {
|
||||
path: Some("/opt/fabro/plugins/sandbox-driver-docker".to_string()),
|
||||
..SandboxPluginSettings::default()
|
||||
}),
|
||||
provider("daytona", true, SandboxPluginSettings::default()),
|
||||
provider("fly-io", false, SandboxPluginSettings {
|
||||
path: Some("/opt/fabro/plugins/sandbox-driver-fly-io".to_string()),
|
||||
..SandboxPluginSettings::default()
|
||||
}),
|
||||
]);
|
||||
let mut env = HashMap::from([("PATH".to_string(), "/bin".to_string())]);
|
||||
for kind in ["HOST", "DOCKER", "DAYTONA"] {
|
||||
let lower = kind.to_ascii_lowercase();
|
||||
env.insert(
|
||||
format!("PETRI_SANDBOX_{kind}_PLUGIN"),
|
||||
format!("/ambient/sandbox-driver-{lower}"),
|
||||
);
|
||||
env.insert(
|
||||
format!("PETRI_SANDBOX_{kind}_SHA256"),
|
||||
"invalid-pin".to_string(),
|
||||
);
|
||||
}
|
||||
let mut cmd = env_command();
|
||||
apply_worker_env_with(&mut cmd, &sandbox_plugin_env(&providers), &|name| {
|
||||
env.get(name).map(OsString::from)
|
||||
});
|
||||
|
||||
let actual = env_output(cmd).await;
|
||||
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_E2B_PLUGIN").map(String::as_str),
|
||||
Some("/opt/fabro/plugins/sandbox-driver-e2b")
|
||||
);
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_E2B_SHA256").map(String::as_str),
|
||||
Some("0123abcd")
|
||||
);
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_PLUGIN_DEV").map(String::as_str),
|
||||
Some("1"),
|
||||
"one plugin in dev mode puts the worker's lookup in dev mode"
|
||||
);
|
||||
for kind in ["HOST", "DOCKER", "DAYTONA"] {
|
||||
for suffix in ["PLUGIN", "SHA256"] {
|
||||
assert!(!actual.contains_key(&format!("PETRI_SANDBOX_{kind}_{suffix}")));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!actual.contains_key("PETRI_SANDBOX_FLY_IO_PLUGIN"),
|
||||
"a disabled kind's plugin does not cross"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_configured_plugin_derives_no_variables() {
|
||||
assert!(sandbox_plugin_env(&ServerSandboxProvidersSettings::default()).is_empty());
|
||||
let mut providers = ServerSandboxProvidersSettings::default();
|
||||
providers
|
||||
.entries
|
||||
.extend([provider("docker", true, SandboxPluginSettings::default())]);
|
||||
assert!(
|
||||
sandbox_plugin_env(&providers).is_empty(),
|
||||
"settings with neither a path nor a pin nor dev mode add nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_graph_allowlist_is_fail_closed() {
|
||||
let env = HashMap::from([
|
||||
|
|
|
|||
|
|
@ -48,16 +48,9 @@ pub(crate) struct WorkerLaunchSpec {
|
|||
pub(crate) fabro_log: Option<String>,
|
||||
pub(crate) active_config_path: PathBuf,
|
||||
pub(crate) github_app_private_key: Option<String>,
|
||||
/// The vault's Daytona API key, for a run on a Daytona environment:
|
||||
/// Petri's Daytona plugin reads it from the worker's process.
|
||||
pub(crate) daytona_api_key: Option<String>,
|
||||
/// The Fabro home the server resolved, so a Petri run's skills step
|
||||
/// reads the same home whatever the worker's environment says.
|
||||
pub(crate) fabro_home: PathBuf,
|
||||
/// The sandbox-driver plugin variables the server's provider settings
|
||||
/// derive (`spawn_env::sandbox_plugin_env`), so Petri in the worker
|
||||
/// launches the plugin the settings name for every configured kind.
|
||||
pub(crate) sandbox_plugin_env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub(crate) struct StartedWorker {
|
||||
|
|
@ -105,7 +98,7 @@ impl LocalWorkerRuntime {
|
|||
.stdout(worker_stdout)
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
apply_worker_env(&mut cmd, &spec.sandbox_plugin_env);
|
||||
apply_worker_env(&mut cmd);
|
||||
if let Some(level) = spec.fabro_log.as_deref() {
|
||||
cmd.env(EnvVars::FABRO_LOG, level);
|
||||
}
|
||||
|
|
@ -116,9 +109,6 @@ impl LocalWorkerRuntime {
|
|||
if let Some(pem) = spec.github_app_private_key.as_deref() {
|
||||
cmd.env(EnvVars::GITHUB_APP_PRIVATE_KEY, pem);
|
||||
}
|
||||
if let Some(key) = spec.daytona_api_key.as_deref() {
|
||||
cmd.env(EnvVars::DAYTONA_API_KEY, key);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fabro_proc::pre_exec_setpgid(cmd.as_std_mut());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
//! Forking a Fabro run at a checkpoint: the seam over Petri's
|
||||
//! `host::fork_from` that rewind, fork and retry are built on (the
|
||||
//! integration plan's F5.1).
|
||||
//! `host::fork_from` that rewind, fork and retry are built on.
|
||||
//!
|
||||
//! Fabro's checkpoint record ties a Petri position `(execution, firing)` to
|
||||
//! a Git commit. A fork seeds a new run from the source's records up to such
|
||||
|
|
@ -48,8 +47,8 @@ use petri_execution::{
|
|||
Access, CoordinatorEvent, ExecutionId, InvocationId, RunKey, RunStore,
|
||||
StoreError as CoordinatorStoreError,
|
||||
};
|
||||
use petri_runtime::RunOptions;
|
||||
use petri_runtime::ir::FiringId;
|
||||
use petri_runtime::{RunOptions, Runtime};
|
||||
use petri_store::StoreError;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
|
|
@ -63,8 +62,6 @@ use crate::providers::{self, SandboxProviderConfig};
|
|||
|
||||
/// One fork to seed.
|
||||
pub struct ForkRequest {
|
||||
/// The provider configuration used by this server-side operation.
|
||||
pub sandbox: SandboxProviderConfig,
|
||||
/// The run whose records are copied.
|
||||
pub source: RunId,
|
||||
/// The new run's id: its Petri run key and its own run scratch.
|
||||
|
|
@ -180,8 +177,9 @@ pub async fn fork(request: ForkRequest) -> Result<Forked, ForkError> {
|
|||
|
||||
let mut options = RunOptions::new(&request.fork_run_dir);
|
||||
options.run_key = Some(fork_key.clone());
|
||||
let runtime = Runtime::standard()
|
||||
.in_process_providers(providers::built_in_providers(&request.sandbox))
|
||||
// A fork only copies records and acquires no sandbox, so it needs no
|
||||
// provider configuration.
|
||||
let runtime = providers::standard_runtime(&SandboxProviderConfig::default())
|
||||
.options(options)
|
||||
.store(Arc::clone(&request.store));
|
||||
let forked = host::fork_from(&runtime, &*source_logs, request.position, ForkOptions {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@
|
|||
//! the vault. Factories connect lazily per run; a Host-only run needs neither
|
||||
//! Docker nor Daytona. Host registry ownership stays with Petri: server
|
||||
//! attach uses an observer instead of these factories.
|
||||
//!
|
||||
//! Every Petri runtime Fabro builds starts from [`standard_runtime`] or
|
||||
//! [`bare_runtime`], so none falls back to launching a provider plugin,
|
||||
//! which a release build cannot verify.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_static::EnvVars;
|
||||
use petri_runtime::{
|
||||
InProcessProviders, ProviderContext, ProviderFactory, ProviderNetwork, fingerprint,
|
||||
InProcessProviders, ProviderContext, ProviderFactory, ProviderNetwork, Runtime, fingerprint,
|
||||
};
|
||||
use sandbox_driver::{AuthError, Error, ProviderKind, SandboxProvider};
|
||||
use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider};
|
||||
|
|
@ -40,13 +45,14 @@ impl DaytonaCredentials {
|
|||
/// Credentials for a vault API key, with the control-plane URL and
|
||||
/// organization taken from `lookup` (server configuration). Nothing is
|
||||
/// read implicitly.
|
||||
///
|
||||
/// These are the two settings the Daytona plugin read in the worker, so
|
||||
/// a lease it recorded keeps its fingerprint: no URL alias and no
|
||||
/// placement target, neither of which reached the plugin.
|
||||
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_api_url(lookup(EnvVars::DAYTONA_API_URL))
|
||||
.with_organization_id(lookup(EnvVars::DAYTONA_ORGANIZATION_ID))
|
||||
.with_target(lookup(EnvVars::DAYTONA_TARGET))
|
||||
}
|
||||
|
||||
/// The control-plane URL; Daytona's public API when `None`.
|
||||
|
|
@ -62,13 +68,6 @@ impl DaytonaCredentials {
|
|||
self
|
||||
}
|
||||
|
||||
/// The configured Daytona placement target, kept unset when omitted.
|
||||
#[must_use]
|
||||
pub fn with_target(mut self, target: Option<String>) -> Self {
|
||||
self.0.target = target;
|
||||
self
|
||||
}
|
||||
|
||||
/// A shared HTTP client; tests pass a no-proxy client here.
|
||||
#[must_use]
|
||||
pub fn with_http_client(mut self, http_client: Option<fabro_http::HttpClient>) -> Self {
|
||||
|
|
@ -88,7 +87,6 @@ impl std::fmt::Debug for DaytonaCredentials {
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -98,20 +96,30 @@ impl std::fmt::Debug for DaytonaCredentials {
|
|||
/// configuration never connects to a backend or requires a credential.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SandboxProviderConfig {
|
||||
pub docker_host: Option<String>,
|
||||
pub docker_host_address: Option<String>,
|
||||
pub daytona: Option<DaytonaCredentials>,
|
||||
docker_host: Option<String>,
|
||||
docker_host_address: Option<String>,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
}
|
||||
|
||||
impl SandboxProviderConfig {
|
||||
/// Snapshot the Docker network/fingerprint selection from the same
|
||||
/// environment the Docker client uses. Daytona credentials are explicit.
|
||||
/// The configuration for `daytona`'s credentials, with how a remote
|
||||
/// Docker daemon's containers reach this machine from `lookup`.
|
||||
///
|
||||
/// The Docker endpoint is read from this process's `DOCKER_HOST`, never
|
||||
/// from `lookup`: the Docker client connects to the daemon that
|
||||
/// variable names, so the lease fingerprint and network name the daemon
|
||||
/// the sandboxes are actually created on.
|
||||
pub fn from_lookup(
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
lookup: impl Fn(&str) -> Option<String>,
|
||||
) -> Self {
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the Docker client reads DOCKER_HOST from this process; the fingerprint must name the same daemon"
|
||||
)]
|
||||
let docker_host = std::env::var(EnvVars::DOCKER_HOST).ok();
|
||||
Self {
|
||||
docker_host: lookup(EnvVars::DOCKER_HOST),
|
||||
docker_host,
|
||||
docker_host_address: lookup(EnvVars::PETRI_SANDBOX_DOCKER_HOST_ADDRESS)
|
||||
.filter(|value| !value.trim().is_empty()),
|
||||
daytona,
|
||||
|
|
@ -119,6 +127,28 @@ impl SandboxProviderConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Petri's standard runtime with Fabro's built-in providers installed.
|
||||
#[must_use]
|
||||
pub fn standard_runtime(config: &SandboxProviderConfig) -> Runtime {
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the one place a standard runtime is built, with the built-in providers installed"
|
||||
)]
|
||||
let runtime = Runtime::standard();
|
||||
runtime.in_process_providers(built_in_providers(config))
|
||||
}
|
||||
|
||||
/// Petri's bare runtime with Fabro's built-in providers installed.
|
||||
#[must_use]
|
||||
pub fn bare_runtime(config: &SandboxProviderConfig) -> Runtime {
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the one place a bare runtime is built, with the built-in providers installed"
|
||||
)]
|
||||
let runtime = Runtime::bare();
|
||||
runtime.in_process_providers(built_in_providers(config))
|
||||
}
|
||||
|
||||
/// The Docker connection used by both the server and Petri. The driver reads
|
||||
/// the caller process's Docker endpoint/TLS environment; health is checked
|
||||
/// by the caller so diagnostics can report an unavailable daemon.
|
||||
|
|
@ -142,7 +172,7 @@ pub async fn connect_daytona(
|
|||
|
||||
/// One lazy factory per built-in kind. Missing Daytona credentials fail
|
||||
/// only when a Daytona scope is acquired, never for admission or a Host run.
|
||||
pub fn built_in_providers(config: &SandboxProviderConfig) -> InProcessProviders {
|
||||
fn built_in_providers(config: &SandboxProviderConfig) -> InProcessProviders {
|
||||
InProcessProviders::new()
|
||||
.with(Arc::new(HostFactory))
|
||||
.with(Arc::new(DockerFactory {
|
||||
|
|
@ -160,12 +190,10 @@ impl ProviderFactory for HostFactory {
|
|||
"host"
|
||||
}
|
||||
|
||||
/// An empty registry path when Petri supplies none, as the plugin
|
||||
/// recorded it.
|
||||
fn fingerprint_seed(&self, context: &ProviderContext) -> String {
|
||||
fingerprint::host(
|
||||
context
|
||||
.host_registry()
|
||||
.expect("Petri supplies the Host registry"),
|
||||
)
|
||||
fingerprint::host(context.host_registry().unwrap_or(Path::new("")))
|
||||
}
|
||||
|
||||
fn network(&self) -> ProviderNetwork {
|
||||
|
|
@ -176,9 +204,12 @@ impl ProviderFactory for HostFactory {
|
|||
&self,
|
||||
context: &ProviderContext,
|
||||
) -> sandbox_driver::Result<Arc<dyn SandboxProvider>> {
|
||||
let registry = context
|
||||
.host_registry()
|
||||
.expect("Petri supplies the Host registry");
|
||||
let registry = context.host_registry().ok_or_else(|| {
|
||||
Error::invalid_spec(
|
||||
"host_registry",
|
||||
"Petri supplied no Host registry for this run",
|
||||
)
|
||||
})?;
|
||||
Ok(Arc::new(HostProvider::with_registry(registry).await?))
|
||||
}
|
||||
}
|
||||
|
|
@ -224,7 +255,7 @@ impl DaytonaFactory {
|
|||
fingerprint::daytona(
|
||||
config.and_then(|config| config.api_url.as_deref()),
|
||||
config.and_then(|config| config.organization_id.as_deref()),
|
||||
config.and_then(|config| config.target.as_deref()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -239,13 +270,6 @@ impl ProviderFactory for DaytonaFactory {
|
|||
self.seed()
|
||||
}
|
||||
|
||||
fn region(&self) -> Option<&str> {
|
||||
self.0
|
||||
.as_ref()
|
||||
.and_then(|credentials| credentials.config().target.as_deref())
|
||||
.filter(|region| !region.is_empty())
|
||||
}
|
||||
|
||||
fn network(&self) -> ProviderNetwork {
|
||||
ProviderNetwork::none()
|
||||
}
|
||||
|
|
@ -277,21 +301,16 @@ mod tests {
|
|||
"docker:unix:///var/run/docker.sock",
|
||||
),
|
||||
] {
|
||||
let config = SandboxProviderConfig::from_lookup(None, |name| {
|
||||
(name == EnvVars::DOCKER_HOST)
|
||||
.then(|| host.map(str::to_owned))
|
||||
.flatten()
|
||||
});
|
||||
let factory = DockerFactory {
|
||||
host: config.docker_host,
|
||||
host_address: config.docker_host_address,
|
||||
host: host.map(str::to_owned),
|
||||
host_address: None,
|
||||
};
|
||||
assert_eq!(factory.seed(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_fingerprint_keeps_unset_values_and_the_configured_target() {
|
||||
fn daytona_fingerprint_keeps_the_plugin_seed() {
|
||||
let unset = DaytonaCredentials::from_api_key("test-key".to_string(), |_| None);
|
||||
assert_eq!(DaytonaFactory(Some(unset)).seed(), "daytona:::");
|
||||
let configured =
|
||||
|
|
@ -303,34 +322,33 @@ mod tests {
|
|||
_ => None,
|
||||
});
|
||||
let factory = DaytonaFactory(Some(configured));
|
||||
assert_eq!(factory.seed(), "daytona:https://daytona.example:org-1:us");
|
||||
assert_eq!(factory.region(), Some("us"));
|
||||
let blank =
|
||||
DaytonaCredentials::new("test-key".to_string()).with_target(Some(String::new()));
|
||||
assert_eq!(DaytonaFactory(Some(blank)).region(), None);
|
||||
assert_eq!(factory.seed(), "daytona:https://daytona.example:org-1:");
|
||||
assert_eq!(factory.region(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_url_alias_and_http_client_survive_the_shared_configuration() {
|
||||
fn daytona_configuration_ignores_the_url_alias_and_keeps_the_http_client() {
|
||||
let credentials = DaytonaCredentials::from_api_key("test-key".to_string(), |name| {
|
||||
(name == EnvVars::DAYTONA_SERVER_URL).then(|| "https://alias.example".to_string())
|
||||
})
|
||||
.with_http_client(Some(fabro_test::test_http_client()));
|
||||
assert_eq!(
|
||||
credentials.config().api_url.as_deref(),
|
||||
Some("https://alias.example")
|
||||
);
|
||||
assert_eq!(credentials.config().api_url, None);
|
||||
assert!(credentials.config().http_client.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_configuration_debug_never_prints_the_key() {
|
||||
fn provider_configuration_keeps_the_key_but_never_prints_it() {
|
||||
let key = "dtn_test_sensitive_value";
|
||||
let config = SandboxProviderConfig::from_lookup(
|
||||
Some(DaytonaCredentials::from_api_key(key.to_string(), |_| None)),
|
||||
Some(DaytonaCredentials::from_api_key(key.to_string(), |name| {
|
||||
(name == EnvVars::DAYTONA_ORGANIZATION_ID).then(|| "org-1".to_string())
|
||||
})),
|
||||
|_| None,
|
||||
);
|
||||
let daytona = config.daytona.as_ref().expect("the credentials are kept");
|
||||
assert_eq!(daytona.config().api_key.as_deref(), Some(key));
|
||||
let rendered = format!("{config:?}");
|
||||
assert!(!rendered.contains(key));
|
||||
assert!(rendered.contains("org-1"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use fabro_types::SandboxProviderKind;
|
|||
pub use petri_execution::prune::PruneReport;
|
||||
use petri_execution::prune::{self as petri_prune};
|
||||
use petri_execution::{RunKey, RunStore};
|
||||
use petri_runtime::{RunOptions, Runtime};
|
||||
use petri_runtime::RunOptions;
|
||||
|
||||
use crate::engine;
|
||||
use crate::providers::{self, SandboxProviderConfig};
|
||||
|
|
@ -72,8 +72,7 @@ pub async fn prune(request: PruneRequest) -> Result<PruneReport, PruneError> {
|
|||
options.run_key = Some(RunKey::new(request.run_id.as_str()));
|
||||
options.retention = engine::RETENTION;
|
||||
options.sandbox.backend = backend;
|
||||
let runtime = Runtime::bare()
|
||||
.in_process_providers(providers::built_in_providers(&request.sandbox))
|
||||
let runtime = providers::bare_runtime(&request.sandbox)
|
||||
.store(request.store)
|
||||
.options(options);
|
||||
petri_prune::prune(&runtime)
|
||||
|
|
|
|||
|
|
@ -70,13 +70,11 @@ impl RuntimeSpec {
|
|||
/// registry: only execution swaps in the stubs.
|
||||
#[must_use]
|
||||
pub fn runtime(&self, for_execution: bool) -> Runtime {
|
||||
let mut runtime = Runtime::standard()
|
||||
.in_process_providers(providers::built_in_providers(&self.sandbox))
|
||||
.frontend(
|
||||
Fabro::new()
|
||||
.with_settings_toml(self.settings_toml.clone())
|
||||
.with_mcp_catalog_toml(self.mcp_catalog_toml.clone()),
|
||||
);
|
||||
let mut runtime = providers::standard_runtime(&self.sandbox).frontend(
|
||||
Fabro::new()
|
||||
.with_settings_toml(self.settings_toml.clone())
|
||||
.with_mcp_catalog_toml(self.mcp_catalog_toml.clone()),
|
||||
);
|
||||
if let Some(client) = &self.model_client {
|
||||
runtime = runtime.capability(PebbleClient(client.clone()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ use fabro_types::{
|
|||
};
|
||||
use petri_execution::host::{self, HostRun};
|
||||
use petri_frontend_fabro::Fabro;
|
||||
use petri_runtime::RunOptions;
|
||||
use petri_runtime::executor::Retention;
|
||||
use petri_runtime::frontend::CompileInputs;
|
||||
use petri_runtime::ir::RunStatus as PetriRunStatus;
|
||||
use petri_runtime::{RunOptions, Runtime};
|
||||
use petri_store::{RunKey, RunStore};
|
||||
use tokio::fs;
|
||||
use tokio::time::sleep;
|
||||
|
|
@ -206,11 +206,8 @@ async fn run_workflow(
|
|||
workflow: &Path,
|
||||
stubs: bool,
|
||||
) {
|
||||
let runtime = Runtime::standard()
|
||||
.in_process_providers(providers::built_in_providers(
|
||||
&SandboxProviderConfig::default(),
|
||||
))
|
||||
.frontend(Fabro::new());
|
||||
let runtime =
|
||||
providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new());
|
||||
let runtime = if stubs {
|
||||
petri_attractor_steps::register_stubs(runtime)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -115,11 +115,7 @@ async fn the_hello_bundle_runs_in_memory_on_the_stub_registry() {
|
|||
.await;
|
||||
let store = Arc::new(MemoryRunStore::new());
|
||||
let rt = petri_attractor_steps::register_stubs(
|
||||
Runtime::standard()
|
||||
.in_process_providers(providers::built_in_providers(
|
||||
&SandboxProviderConfig::default(),
|
||||
))
|
||||
.frontend(Fabro::new()),
|
||||
providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new()),
|
||||
)
|
||||
.store(store.clone())
|
||||
.options(run_options(&root.path().join("run"), "hello"));
|
||||
|
|
@ -144,11 +140,7 @@ async fn a_command_workflow_runs_on_the_host_sandbox() {
|
|||
.await;
|
||||
let store = Arc::new(MemoryRunStore::new());
|
||||
let rt = petri_attractor_steps::register(
|
||||
Runtime::standard()
|
||||
.in_process_providers(providers::built_in_providers(
|
||||
&SandboxProviderConfig::default(),
|
||||
))
|
||||
.frontend(Fabro::new()),
|
||||
providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new()),
|
||||
)
|
||||
.store(store.clone())
|
||||
.options(run_options(&root.path().join("run"), "command"));
|
||||
|
|
|
|||
|
|
@ -162,25 +162,47 @@ pub const REQUIRE_SANDBOX_BACKENDS: &str = "FABRO_REQUIRE_SANDBOX_BACKENDS";
|
|||
/// A reachable Docker daemon. When [`REQUIRE_SANDBOX_BACKENDS`] is set, a
|
||||
/// missing daemon fails the test instead of skipping it.
|
||||
#[must_use]
|
||||
pub fn docker_available() -> bool {
|
||||
sandbox_backend_available(
|
||||
docker_succeeds(&["version", "--format", "{{.Server.Version}}"]),
|
||||
"no Docker daemon answers",
|
||||
)
|
||||
}
|
||||
|
||||
/// A Docker daemon that already holds `image`, under the same
|
||||
/// fail-or-skip policy as [`docker_available`].
|
||||
#[must_use]
|
||||
pub fn docker_image_available(image: &str) -> bool {
|
||||
sandbox_backend_available(
|
||||
docker_succeeds(&["image", "inspect", image]),
|
||||
&format!("no Docker daemon with {image}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn docker_succeeds(args: &[&str]) -> bool {
|
||||
std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
/// `available`, or a skip notice for `missing` (a failure when
|
||||
/// [`REQUIRE_SANDBOX_BACKENDS`] is set).
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "Skip notices go to stderr so stdout stays assertable."
|
||||
)]
|
||||
pub fn docker_available() -> bool {
|
||||
let daemon = std::process::Command::new("docker")
|
||||
.args(["version", "--format", "{{.Server.Version}}"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
if !daemon {
|
||||
fn sandbox_backend_available(available: bool, missing: &str) -> bool {
|
||||
if !available {
|
||||
assert!(
|
||||
std::env::var_os(REQUIRE_SANDBOX_BACKENDS).is_none(),
|
||||
"{REQUIRE_SANDBOX_BACKENDS} is set, but no Docker daemon answers"
|
||||
"{REQUIRE_SANDBOX_BACKENDS} is set, but {missing}"
|
||||
);
|
||||
eprintln!("skipping: no Docker daemon answers");
|
||||
eprintln!("skipping: {missing}");
|
||||
}
|
||||
daemon
|
||||
available
|
||||
}
|
||||
|
||||
/// Apply baseline environment isolation to a `Command` that spawns the
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue