diff --git a/Cargo.lock b/Cargo.lock index 69ca55681..fe3abe860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5160,7 +5160,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petri-attractor-steps" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "globset", @@ -5191,7 +5191,7 @@ dependencies = [ [[package]] name = "petri-driver" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "getrandom 0.3.4", @@ -5211,7 +5211,7 @@ dependencies = [ [[package]] name = "petri-engine" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "petri-ir", "serde", @@ -5223,7 +5223,7 @@ dependencies = [ [[package]] name = "petri-execution" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "petri-driver", @@ -5247,7 +5247,7 @@ dependencies = [ [[package]] name = "petri-executor" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "libc", @@ -5262,7 +5262,7 @@ dependencies = [ [[package]] name = "petri-executor-sandbox" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "petri-executor", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "petri-frontend" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "marked-yaml", "petri-ir", @@ -5298,7 +5298,7 @@ dependencies = [ [[package]] name = "petri-frontend-attractor" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "minijinja", "petri-frontend", @@ -5315,7 +5315,7 @@ dependencies = [ [[package]] name = "petri-frontend-fabro" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "petri-frontend", "petri-frontend-attractor", @@ -5331,7 +5331,7 @@ dependencies = [ [[package]] name = "petri-frontend-native" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "petri-frontend", "petri-ir", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "petri-ir" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "regex", "serde", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "petri-runtime" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "petri-driver", @@ -5376,7 +5376,7 @@ dependencies = [ [[package]] name = "petri-steps" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "petri-executor", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "petri-store" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "getrandom 0.3.4", @@ -5407,7 +5407,7 @@ dependencies = [ [[package]] name = "petri-testkit" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#e0e48d6d7f6b6af86e52a25e6abaf250f36874e6" dependencies = [ "async-trait", "petri-driver", diff --git a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs index 8e2e4517b..72ab7365e 100644 --- a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs +++ b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs @@ -219,6 +219,14 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> { .environment .provider .clone(), + resources: worker + .run_state + .spec + .settings + .run + .environment + .resources + .clone(), cancel: cancel_token.clone(), controls: controls.clone(), interviewer: Arc::new(petri_interviewer), diff --git a/lib/apps/fabro-server/src/server/petri_runs.rs b/lib/apps/fabro-server/src/server/petri_runs.rs index 9c30a7042..0a7e596a1 100644 --- a/lib/apps/fabro-server/src/server/petri_runs.rs +++ b/lib/apps/fabro-server/src/server/petri_runs.rs @@ -487,6 +487,7 @@ pub(crate) async fn execute(state: Arc, run_id: RunId) { }), runtime, provider: run_state.spec.settings.run.environment.provider.clone(), + resources: run_state.spec.settings.run.environment.resources.clone(), cancel, // The in-process test path drives no pause: the server's transport // for it names the worker. A steer or an interrupt is answered in diff --git a/lib/components/fabro-petri/src/engine.rs b/lib/components/fabro-petri/src/engine.rs index e4f63483b..4d016f846 100644 --- a/lib/components/fabro-petri/src/engine.rs +++ b/lib/components/fabro-petri/src/engine.rs @@ -47,6 +47,8 @@ use std::path::PathBuf; use std::sync::Arc; +use fabro_types::settings::run::EnvironmentResourcesSettings; +use fabro_types::settings::size::Size; use fabro_types::{FailureReason, RunId, SandboxProviderKind}; use petri_execution::host::{self, HostError, HostRun}; use petri_execution::inspect::{self, InspectError, RunInspection}; @@ -57,7 +59,9 @@ use petri_execution::{ use petri_runtime::driver::lifecycle::ExecutionHooks; pub use petri_runtime::executor::Retention; use petri_runtime::executor::SecretProvider; -use petri_runtime::{LostSandbox, RunOptions, SandboxBackend}; +use petri_runtime::{ + DaytonaResources, DaytonaSandboxKind, LostSandbox, RunOptions, SandboxBackend, +}; use tokio::fs; use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; @@ -94,6 +98,8 @@ pub struct RunRequest { pub runtime: RuntimeSpec, /// The sandbox provider Fabro resolved for the run's environment. pub provider: SandboxProviderKind, + /// Resolved environment resources for the Daytona runner snapshot. + pub resources: EnvironmentResourcesSettings, /// Fires to cancel the run. pub cancel: CancellationToken, /// The run's pause, unpause and steer controls, which the caller keeps @@ -142,6 +148,8 @@ pub struct RunOutcome { pub enum RunError { #[error("the run's sandbox provider `{provider}` is not one Petri serves")] UnsupportedProvider { provider: SandboxProviderKind }, + #[error("the Daytona CPU allocation must be a non-negative integer: {cpu}")] + InvalidCpu { cpu: i32 }, #[error("the run's record could not be opened")] Open(#[source] petri_store::StoreError), #[error("the run's record could not be read")] @@ -184,6 +192,12 @@ pub async fn run(request: RunRequest) -> Result { options.run_key = Some(key.clone()); options.retention = RETENTION; options.sandbox.backend = backend; + // Fabro runs on Daytona's generally available containers; Petri's VM + // default requires runner capacity that ordinary accounts may not have. + options.sandbox.daytona_kind = DaytonaSandboxKind::Container; + if backend == SandboxBackend::Daytona { + options.sandbox.daytona_resources = daytona_resources(&request.resources)?; + } // Fabro's hooks restore a sandbox workspace from its snapshots at the // scope's acquisition, so a lease whose sandbox is gone gets a fresh // one instead of failing the run. @@ -392,6 +406,30 @@ fn error_chain(error: &RunError) -> String { parts.join(": ") } +/// Keep the configured allocation, leaving an unspecified disk to Daytona. +fn daytona_resources( + settings: &EnvironmentResourcesSettings, +) -> Result { + let defaults = DaytonaResources::default(); + Ok(DaytonaResources { + cpu_cores: settings + .cpu + .map(|cpu| u32::try_from(cpu).map_err(|_| RunError::InvalidCpu { cpu })) + .transpose()? + .unwrap_or(defaults.cpu_cores), + memory_mb: settings + .memory + .map_or(defaults.memory_mb, daytona_mebibytes), + disk_mb: settings.disk.map(daytona_mebibytes), + }) +} + +fn daytona_mebibytes(size: Size) -> u64 { + // Daytona allocates whole GiB. Round up as the driver does before + // Petri validates the minimum and compares the resolved snapshot size. + size.as_bytes().div_ceil(1024 * 1024 * 1024) * 1024 +} + /// The sandbox backend for Fabro's provider kind; `None` for a kind Petri /// does not serve. pub(crate) fn backend(provider: &SandboxProviderKind) -> Option { diff --git a/lib/components/fabro-petri/tests/daytona.rs b/lib/components/fabro-petri/tests/daytona.rs new file mode 100644 index 000000000..3e1a2e39b --- /dev/null +++ b/lib/components/fabro-petri/tests/daytona.rs @@ -0,0 +1,156 @@ +//! Fabro's Daytona selection reaches the provider through Petri's real +//! executor. + +mod support; + +use std::sync::Arc; + +use fabro_petri::check::Launch; +use fabro_petri::engine::{self, RunStatus}; +use fabro_petri::providers::{DaytonaCredentials, SandboxProviderConfig}; +use fabro_petri::runtime::RuntimeSpec; +use fabro_types::SandboxProviderKind; +use fabro_types::settings::run::EnvironmentResourcesSettings; +use httpmock::prelude::*; +use petri_store::MemoryRunStore; +use serde_json::json; + +#[tokio::test] +async fn daytona_runs_request_container_runner_snapshots() { + assert_snapshot_request(EnvironmentResourcesSettings::default(), 2, 4, None).await; +} + +#[tokio::test] +async fn daytona_runs_forward_configured_resources_in_provider_units() { + assert_snapshot_request( + EnvironmentResourcesSettings { + cpu: Some(4), + memory: Some("6GB".parse().unwrap()), + disk: Some("8GB".parse().unwrap()), + }, + 4, + 6, + Some(8), + ) + .await; +} + +#[tokio::test] +async fn daytona_decimal_memory_meets_the_runner_minimum_without_defaulting_disk() { + assert_snapshot_request( + EnvironmentResourcesSettings { + cpu: Some(2), + memory: Some("4GB".parse().unwrap()), + disk: None, + }, + 2, + 4, + None, + ) + .await; +} + +async fn assert_snapshot_request( + resources: EnvironmentResourcesSettings, + cpu: u32, + memory: u64, + disk: Option, +) { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(GET).path("/api-keys/current"); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "name": "test-key", + "organizationId": "test-org", + "permissions": [ + "write:snapshots", "delete:snapshots", + "write:sandboxes", "delete:sandboxes" + ] + })); + }) + .await; + server + .mock_async(|when, then| { + when.method(GET).path("/sandbox"); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({"items": [], "nextCursor": null})); + }) + .await; + server + .mock_async(|when, then| { + when.method(GET).path_matches(r"^/snapshots/[^/]+$"); + then.status(404) + .header("content-type", "application/json") + .json_body(json!({"message": "not found"})); + }) + .await; + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/snapshots") + .json_body_includes( + json!({"sandboxClass": "container", "cpu": cpu, "memory": memory}).to_string(), + ) + .is_true(move |request| { + let Ok(body) = serde_json::from_slice::(request.body_ref()) + else { + return false; + }; + match disk { + Some(disk) => body.get("disk") == Some(&json!(disk)), + None => body.get("disk").is_none(), + } + }); + // Stop at the provider boundary: this test proves the wire + // contract without creating a sandbox or simulating its shell. + then.status(400) + .header("content-type", "application/json") + .json_body(json!({"message": "snapshot creation stopped by test"})); + }) + .await; + let credentials = DaytonaCredentials::new("test-key".to_string()) + .with_api_url(Some(server.base_url())) + .with_http_client(Some(fabro_test::test_http_client())); + let runtime = RuntimeSpec { + sandbox: SandboxProviderConfig::from_lookup(Some(credentials), |_| None), + ..RuntimeSpec::default() + }; + let graphs = support::admit( + &[ + ("workflow.toml", support::SETTINGS), + ( + "workflow.fabro", + r#"digraph Smoke { + start [shape=Mdiamond] + work [shape=parallelogram, script="echo smoke"] + exit [shape=Msquare] + start -> work -> exit + }"#, + ), + ], + Launch::default(), + &runtime, + ); + let root = tempfile::tempdir().expect("a temporary run directory"); + let mut request = support::run_request( + "daytona-container", + root.path(), + graphs, + Arc::new(MemoryRunStore::new()), + runtime, + support::no_questions(Arc::new(support::Silent)), + ); + request.provider = SandboxProviderKind::DAYTONA; + request.resources = resources; + + let outcome = engine::run(request) + .await + .expect("the run records its failure"); + + assert_eq!(create.calls_async().await, 1, "{outcome:?}"); + assert_eq!(outcome.status, RunStatus::Failed); +} diff --git a/lib/components/fabro-petri/tests/hooks.rs b/lib/components/fabro-petri/tests/hooks.rs index 6d2b1c80d..1139745c9 100644 --- a/lib/components/fabro-petri/tests/hooks.rs +++ b/lib/components/fabro-petri/tests/hooks.rs @@ -34,7 +34,7 @@ use fabro_petri::recovery::{self, Recovery, RecoveryRequest}; use fabro_petri::runtime::RuntimeSpec; use fabro_petri::test_support::{MemoryBlobs, MemoryPlatformRecords}; use fabro_store::{ArtifactStore, PlatformRecord, PlatformRecordKind}; -use fabro_types::settings::run::RunCheckpointSettings; +use fabro_types::settings::run::{EnvironmentResourcesSettings, RunCheckpointSettings}; use fabro_types::{GitIdentitySource, RunId, SandboxProviderKind}; use object_store::local::LocalFileSystem; use petri_execution::inspect::{self, RunInspection}; @@ -163,6 +163,7 @@ impl Harness { ..RuntimeSpec::default() }, provider, + resources: EnvironmentResourcesSettings::default(), cancel: CancellationToken::new(), controls: RunControls::new(), interviewer, @@ -827,6 +828,7 @@ async fn a_run_hook_blocks_a_tool_effect_through_the_forwarded_service() { ..RuntimeSpec::default() }, provider: SandboxProviderKind::LOCAL, + resources: EnvironmentResourcesSettings::default(), cancel: CancellationToken::new(), controls: RunControls::new(), interviewer, diff --git a/lib/components/fabro-petri/tests/support/mod.rs b/lib/components/fabro-petri/tests/support/mod.rs index 0bc63d1cf..eaefa21e1 100644 --- a/lib/components/fabro-petri/tests/support/mod.rs +++ b/lib/components/fabro-petri/tests/support/mod.rs @@ -19,6 +19,7 @@ use fabro_petri::engine::{Execution, RunRequest}; use fabro_petri::interview::{Approval, FabroInterviewer, QuestionNotice, QuestionSink}; use fabro_petri::runtime::RuntimeSpec; use fabro_types::SandboxProviderKind; +use fabro_types::settings::run::EnvironmentResourcesSettings; use petri_execution::inspect; use petri_store::{Access, LogId, RunKey, RunStore}; use tokio::time::sleep; @@ -84,6 +85,7 @@ pub(crate) fn run_request( store, runtime, provider: SandboxProviderKind::LOCAL, + resources: EnvironmentResourcesSettings::default(), cancel: CancellationToken::new(), controls: RunControls::new(), observers: vec![interviewer.observer()],