Restore Daytona container runs and configured resources

This commit is contained in:
Scott Werner 2026-09-28 23:02:34 -04:00
parent 24fb18869c
commit c6464fb646
7 changed files with 224 additions and 17 deletions

30
Cargo.lock generated
View file

@ -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",

View file

@ -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),

View file

@ -487,6 +487,7 @@ pub(crate) async fn execute(state: Arc<AppState>, 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

View file

@ -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<RunOutcome, RunError> {
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<DaytonaResources, RunError> {
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<SandboxBackend> {

View file

@ -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<u64>,
) {
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::<serde_json::Value>(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);
}

View file

@ -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,

View file

@ -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()],