Add Daytona volume mount passthrough (#263)

## Summary

This adds run-configuration support for mounting existing Daytona
volumes into Fabro-managed Daytona sandboxes.

Concretely, this PR:

- adds `[[run.sandbox.daytona.volumes]]` with `volume_id`, `mount_path`,
and optional `subpath`
- resolves that config through the layer/settings/runtime pipeline
- forwards configured mounts to `daytona_sdk::SandboxBaseParams.volumes`
when creating the sandbox
- documents the configuration surface in the Daytona environment and run
configuration docs

## Motivation

Daytona already supports attaching volumes when a sandbox is created,
but Fabro currently owns that sandbox creation call. That means users
cannot attach a pre-created Daytona volume to a Fabro-managed sandbox
from run config.

The intended use is persistent, provider-owned state such as agent
credentials, caches, datasets, or other files that should survive
ephemeral sandbox lifecycles.

## Scope

This is intentionally a narrow passthrough. Fabro does not create,
delete, list, wait on, or otherwise manage Daytona volume lifecycle.
Users create the volume in Daytona first, then reference its `volume_id`
from Fabro run config.

`volumes` defaults to an empty list in resolved settings for backwards
compatibility with existing serialized settings.

## Testing

- `cargo test -p fabro-server
runtime_daytona_config_preserves_volume_mounts`
- `cargo test -p fabro-config resolves_daytona_volume_mounts`
- `cargo test -p fabro-sandbox --features daytona volume_mounts`
- `cargo test -p fabro-workflow
runtime_daytona_config_preserves_volume_mounts`
- `cargo check -p fabro-server`

---

_Re-opened from #262 (originally by @kimprobably) to land a rustfmt fix
— the original PR came from an org-owned fork, which blocks maintainer
pushes. Branch is now on the base repo. Original commit preserved; one
additional commit fixes rustfmt formatting._

Co-authored-by: Tim Keen <tim@keen.digital>
This commit is contained in:
Bryan Helmkamp 2026-05-14 07:54:43 -07:00 committed by GitHub
parent c0fe29390a
commit e7e4fb5ca1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 248 additions and 27 deletions

View file

@ -160,6 +160,22 @@ dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get instal
If the snapshot already exists and is in `Active` state, Fabro uses it directly. If it's in `Building` or `Pending` state, Fabro polls with exponential backoff until it's ready.
### Volumes
Mount existing Daytona volumes into each sandbox at creation time:
```toml title="run.toml"
[run.sandbox]
provider = "daytona"
[[run.sandbox.daytona.volumes]]
volume_id = "vol-agent-state"
mount_path = "/home/daytona/agent-state"
subpath = "agent-auth"
```
Fabro does not create or manage Daytona volumes. Create the volume in Daytona first, then reference its `volume_id` from the run config. Use `subpath` when a shared volume should expose only one prefix to the sandbox.
### Labels
Attach key-value labels to sandboxes for filtering and identification in the Daytona dashboard:

View file

@ -273,6 +273,11 @@ auto_stop_interval = 60
project = "fabro"
env = "staging"
[[run.sandbox.daytona.volumes]]
volume_id = "vol-agent-state"
mount_path = "/home/daytona/agent-state"
subpath = "agent-auth"
[run.sandbox.daytona.snapshot]
name = "my-snapshot"
cpu = 4
@ -287,6 +292,7 @@ dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update"
|---|---|
| `auto_stop_interval` | Minutes of inactivity before the sandbox auto-stops. |
| `labels` | Key-value labels attached to the sandbox for filtering and identification. Labels merge across layers (sticky merge-by-key). |
| `volumes` | Existing Daytona volumes to mount at sandbox creation. Each entry requires `volume_id` and `mount_path`; `subpath` is optional. Fabro does not create or manage volume lifecycle. |
| `snapshot.name` | Snapshot name to create or use for the sandbox. |
| `snapshot.cpu` | CPU cores for the snapshot (integer). |
| `snapshot.memory` | Memory size using human-readable units: `"8GB"`, `"16GiB"`, or bare integers that default to GB. |

View file

@ -15,9 +15,9 @@ use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
use super::features::FeaturesLayer;
use super::llm::{CostRates, CredentialRef, HeaderValueRef, ReasoningEffortFeature};
use super::run::{
DaytonaSnapshotLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer,
ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer,
RunGoalLayer, RunPrepareLayer, ScmGitHubLayer, StringOrSplice,
DaytonaSnapshotLayer, DaytonaVolumeLayer, HookAgentMarker, HookEntry, HookTlsMode,
InterviewProviderLayer, ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer,
RunCheckpointLayer, RunGoalLayer, RunPrepareLayer, ScmGitHubLayer, StringOrSplice,
};
use super::server::{
ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer,
@ -43,6 +43,12 @@ impl<T: Combine> Combine for Option<T> {
}
}
impl Combine for Option<Vec<DaytonaVolumeLayer>> {
fn combine(self, other: Self) -> Self {
self.or(other)
}
}
macro_rules! impl_combine_or_option {
($($ty:ty),+ $(,)?) => {
$(

View file

@ -26,14 +26,14 @@ pub use log_filter::LogFilter;
pub use maps::{MergeMap, ReplaceMap, StickyMap};
pub use project::ProjectLayer;
pub use run::{
DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, DockerSandboxLayer,
GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer,
InterviewsLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer,
NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer,
RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer,
RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer,
RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunSandboxLayer, RunScmLayer,
ScmGitHubLayer, StringOrSplice,
DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, DaytonaVolumeLayer,
DockerSandboxLayer, GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode,
InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice,
NotificationProviderLayer, NotificationRouteLayer, PrepareStep, RunAgentLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer,
RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer,
RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer,
RunSandboxLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice,
};
pub use server::{
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,

View file

@ -356,12 +356,24 @@ pub struct DaytonaSandboxLayer {
/// Sticky merge-by-key (provider-native labels).
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub labels: StickyMap<String>,
/// Existing Daytona volumes to mount when creating the sandbox.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volumes: Option<Vec<DaytonaVolumeLayer>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<DaytonaSnapshotLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<DaytonaNetworkLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DaytonaVolumeLayer {
pub volume_id: String,
pub mount_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subpath: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DaytonaSnapshotLayer {

View file

@ -42,10 +42,10 @@ pub use layers::{
CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer,
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef,
CredentialRefParseError, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer,
DockerSandboxLayer, FeaturesLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef,
HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer,
InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, McpEntryLayer,
MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
DaytonaVolumeLayer, DockerSandboxLayer, FeaturesLayer, GitAuthorLayer, GithubIntegrationLayer,
HeaderValueRef, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer,
InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter,
McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer,

View file

@ -1,13 +1,13 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerSettings, DockerfileSource,
GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings,
McpTransport, MergeStrategy, NotificationProviderSettings, NotificationRouteSettings,
PullRequestSettings, RunAgentSettings, RunBranchSettings, RunCheckpointSettings,
RunCloneSettings, RunExecutionSettings, RunGitSettings, RunGoal, RunIntegrationsGithubSettings,
RunIntegrationsSettings, RunInterviewsSettings, RunMetaBranchSettings, RunModelControls,
RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings,
ScmGitHubSettings, TlsMode,
ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DaytonaVolumeSettings,
DockerSettings, DockerfileSource, GitAuthorSettings, HookDefinition, HookType,
InterviewProviderSettings, McpServerSettings, McpTransport, MergeStrategy,
NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings, RunAgentSettings,
RunBranchSettings, RunCheckpointSettings, RunCloneSettings, RunExecutionSettings,
RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings,
RunInterviewsSettings, RunMetaBranchSettings, RunModelControls, RunModelSettings, RunNamespace,
RunPrepareSettings, RunSandboxSettings, RunScmSettings, ScmGitHubSettings, TlsMode,
};
use super::ResolveError;
@ -269,6 +269,17 @@ fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings {
DaytonaSettings {
auto_stop_interval: daytona.auto_stop_interval,
labels: daytona.labels.clone().into_inner(),
volumes: daytona
.volumes
.as_deref()
.unwrap_or(&[])
.iter()
.map(|volume| DaytonaVolumeSettings {
volume_id: volume.volume_id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect(),
snapshot: daytona.snapshot.as_ref().and_then(|snapshot| {
snapshot.name.as_ref().map(|name| DaytonaSnapshotSettings {
name: name.clone(),

View file

@ -61,6 +61,36 @@ fn resolves_run_defaults_from_empty_settings() {
assert!(settings.pull_request.is_none());
}
#[test]
fn resolves_daytona_volume_mounts() {
let settings = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.sandbox]
provider = "daytona"
[[run.sandbox.daytona.volumes]]
volume_id = "vol_auth"
mount_path = "/home/daytona/.config"
subpath = "agents"
"#,
)
.expect("daytona volume mount should resolve")
.run;
let daytona = settings
.sandbox
.daytona
.as_ref()
.expect("daytona settings should resolve");
assert_eq!(daytona.volumes.len(), 1);
assert_eq!(daytona.volumes[0].volume_id, "vol_auth");
assert_eq!(daytona.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(daytona.volumes[0].subpath.as_deref(), Some("agents"));
}
#[test]
fn resolves_run_level_clone_branch_controls() {
let settings = WorkflowSettingsBuilder::from_toml(

View file

@ -16,12 +16,21 @@ use serde::{Deserialize, Serialize};
pub struct DaytonaSettings {
pub auto_stop_interval: Option<i32>,
pub labels: Option<HashMap<String, String>>,
#[serde(default)]
pub volumes: Vec<DaytonaVolumeMount>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetwork>,
#[serde(default)]
pub skip_clone: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct DaytonaVolumeMount {
pub volume_id: String,
pub mount_path: String,
pub subpath: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DaytonaNetwork {
Block,

View file

@ -52,7 +52,7 @@ pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[
pub use crate::config::{
DaytonaNetwork, DaytonaSettings as DaytonaConfig,
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource,
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DaytonaVolumeMount, DockerfileSource,
};
#[derive(Debug)]
@ -103,6 +103,20 @@ fn perm_wire_str(permission: Permissions) -> &'static str {
}
}
fn volume_mounts_for_create(config: &DaytonaConfig) -> Option<Vec<daytona_sdk::VolumeMount>> {
(!config.volumes.is_empty()).then(|| {
config
.volumes
.iter()
.map(|volume| daytona_sdk::VolumeMount {
volume_id: volume.volume_id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect()
})
}
/// Build a [`daytona_sdk::Client`], forwarding an optional API key from the
/// vault so the SDK doesn't have to rely on `DAYTONA_API_KEY` being in the
/// process environment.
@ -448,6 +462,7 @@ impl DaytonaSandbox {
ephemeral: Some(false),
network_block_all,
network_allow_list,
volumes: volume_mounts_for_create(&self.config),
..Default::default()
}
}
@ -2168,6 +2183,45 @@ mod tests {
assert!(config.snapshot.is_none());
assert!(config.auto_stop_interval.is_none());
assert!(config.labels.is_none());
assert!(config.volumes.is_empty());
}
#[test]
fn parses_volume_mounts_from_config() {
let config: DaytonaConfig = toml::from_str(
r#"
[[volumes]]
volume_id = "vol_auth"
mount_path = "/home/daytona/.config"
subpath = "agents"
"#,
)
.expect("volume config should parse");
assert_eq!(config.volumes, vec![DaytonaVolumeMount {
volume_id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}]);
}
#[test]
fn maps_volume_mounts_to_daytona_create_params() {
let config = DaytonaConfig {
volumes: vec![DaytonaVolumeMount {
volume_id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}],
..DaytonaConfig::default()
};
let volumes = volume_mounts_for_create(&config).expect("volumes should map");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].volume_id, "vol_auth");
assert_eq!(volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(volumes[0].subpath.as_deref(), Some("agents"));
}
#[tokio::test]

View file

@ -1673,6 +1673,7 @@ mod runs {
"project".to_string(),
"api-server".to_string(),
)]),
volumes: Vec::new(),
snapshot: Some(DaytonaSnapshotSettings {
name: "api-server-dev".into(),
cpu: Some(4),

View file

@ -17,7 +17,8 @@ use fabro_graphviz::render::apply_direction;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::config::{
DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource,
DaytonaNetwork, DaytonaSnapshotSettings, DaytonaVolumeMount,
DockerfileSource as SandboxDockerfileSource,
};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::redact::redact_auth_url;
@ -1097,6 +1098,15 @@ fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> Dayto
DaytonaConfig {
auto_stop_interval: settings.auto_stop_interval,
labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()),
volumes: settings
.volumes
.iter()
.map(|volume| DaytonaVolumeMount {
volume_id: volume.volume_id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect(),
snapshot: settings
.snapshot
.as_ref()
@ -1433,6 +1443,25 @@ enabled = {clone_enabled}
(prepared, resolved)
}
#[test]
fn runtime_daytona_config_preserves_volume_mounts() {
let settings = DaytonaSettings {
volumes: vec![fabro_types::settings::run::DaytonaVolumeSettings {
volume_id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}],
..DaytonaSettings::default()
};
let config = runtime_daytona_config(&settings, false);
assert_eq!(config.volumes.len(), 1);
assert_eq!(config.volumes[0].volume_id, "vol_auth");
assert_eq!(config.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(config.volumes[0].subpath.as_deref(), Some("agents"));
}
#[test]
fn prepare_manifest_inlines_project_config_daytona_dockerfile_from_bundle() {
let mut manifest = minimal_manifest();

View file

@ -324,10 +324,19 @@ pub struct DockerSettings {
pub struct DaytonaSettings {
pub auto_stop_interval: Option<i32>,
pub labels: HashMap<String, String>,
#[serde(default)]
pub volumes: Vec<DaytonaVolumeSettings>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetworkLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DaytonaVolumeSettings {
pub volume_id: String,
pub mount_path: String,
pub subpath: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DockerfileSource {
Inline(String),

View file

@ -8,7 +8,8 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, Provider, ProviderId, adapter};
use fabro_sandbox::config::{
DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource,
DaytonaNetwork, DaytonaSnapshotSettings, DaytonaVolumeMount,
DockerfileSource as SandboxDockerfileSource,
};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{DockerSandboxOptions, SandboxProvider, SandboxSpec};
@ -680,6 +681,15 @@ fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> Dayto
DaytonaConfig {
auto_stop_interval: settings.auto_stop_interval,
labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()),
volumes: settings
.volumes
.iter()
.map(|volume| DaytonaVolumeMount {
volume_id: volume.volume_id.clone(),
mount_path: volume.mount_path.clone(),
subpath: volume.subpath.clone(),
})
.collect(),
snapshot: settings
.snapshot
.as_ref()
@ -1117,7 +1127,10 @@ mod tests {
use std::time::Duration;
use chrono::Utc;
use fabro_config::{RunCloneLayer, RunExecutionLayer, RunLayer, WorkflowSettingsBuilder};
use fabro_config::{
DaytonaSandboxLayer, DaytonaVolumeLayer, RunCloneLayer, RunExecutionLayer, RunLayer,
RunSandboxLayer, WorkflowSettingsBuilder,
};
use fabro_model::catalog::LlmCatalogSettings;
use fabro_store::Database;
use fabro_types::settings::ModelRef;
@ -1232,6 +1245,31 @@ mod tests {
assert!(resolve_daytona_config(&settings.run).skip_clone);
}
#[test]
fn runtime_daytona_config_preserves_volume_mounts() {
let settings = settings_from_run_layer(RunLayer {
sandbox: Some(RunSandboxLayer {
daytona: Some(DaytonaSandboxLayer {
volumes: Some(vec![DaytonaVolumeLayer {
volume_id: "vol_auth".to_string(),
mount_path: "/home/daytona/.config".to_string(),
subpath: Some("agents".to_string()),
}]),
..DaytonaSandboxLayer::default()
}),
..RunSandboxLayer::default()
}),
..RunLayer::default()
});
let config = resolve_daytona_config(&settings.run);
assert_eq!(config.volumes.len(), 1);
assert_eq!(config.volumes[0].volume_id, "vol_auth");
assert_eq!(config.volumes[0].mount_path, "/home/daytona/.config");
assert_eq!(config.volumes[0].subpath.as_deref(), Some("agents"));
}
#[test]
fn start_record_git_options_honor_disabled_run_branch() {
let mut settings = WorkflowSettings::default();