mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Merge pull request #819 from fabro-sh/codex/daytona-image-snapshots
Allow Daytona snapshots from Docker images
This commit is contained in:
commit
9dd47a8cd3
12 changed files with 294 additions and 150 deletions
21
docs/public/changelog/2026-08-27.mdx
Normal file
21
docs/public/changelog/2026-08-27.mdx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
title: "Docker images for Daytona snapshots"
|
||||
date: "2026-08-27"
|
||||
---
|
||||
|
||||
Daytona environments now accept `image.docker`. Fabro creates or reuses a
|
||||
Daytona snapshot from that image, so an existing image no longer needs a
|
||||
wrapper Dockerfile. `image.dockerfile` remains available for custom builds.
|
||||
|
||||
Set one image source per Daytona environment:
|
||||
|
||||
```toml
|
||||
[environments.cloud]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
docker = "python:3.11-slim"
|
||||
```
|
||||
|
||||
Fabro rejects a Daytona environment that sets both `image.docker` and
|
||||
`image.dockerfile`.
|
||||
|
|
@ -309,6 +309,7 @@
|
|||
"group": "August 2026",
|
||||
"icon": "clock-rotate-left",
|
||||
"pages": [
|
||||
"changelog/2026-08-27",
|
||||
"changelog/2026-08-26",
|
||||
"changelog/2026-08-25",
|
||||
"changelog/2026-08-23",
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ Install seeds a `default` environment into SQLite. It is a normal persisted envi
|
|||
|
||||
| Environment field | Local | Docker | Daytona |
|
||||
|---|---|---|---|
|
||||
| `image.docker` | Ignored | Docker image | Error |
|
||||
| `image.docker` | Ignored | Docker image | Snapshot base image; Fabro computes the snapshot name |
|
||||
| `image.dockerfile` | Ignored | Warning; ignored | Snapshot Dockerfile; Fabro computes the snapshot name |
|
||||
| `resources.cpu` | Warning; ignored | `cpu_quota = cpu * 100000` | Snapshot CPU |
|
||||
| `resources.memory` | Warning; ignored | Container memory limit | Snapshot memory |
|
||||
|
|
@ -276,14 +276,14 @@ The image must provide `/bin/bash`; Fabro evaluates every sandbox command with i
|
|||
|
||||
## Daytona
|
||||
|
||||
Daytona runs tools in a cloud sandbox. Without `image.dockerfile`, Fabro uses Daytona's built-in `daytona-medium` snapshot. With `image.dockerfile`, Fabro computes a deterministic internal snapshot name from the Dockerfile, resource hints, a single-tenant scope, and the Daytona API key.
|
||||
Daytona runs tools in a cloud sandbox. Set either `image.docker` to use an existing Docker image or `image.dockerfile` to build a custom image. Fabro computes a deterministic internal snapshot name from the selected image source, resource hints, a single-tenant scope, and the Daytona API key. If neither field is set, Fabro uses Daytona's built-in `daytona-medium` snapshot.
|
||||
|
||||
```toml title="workflow.toml"
|
||||
[environments.cloud]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
docker = "python:3.11-slim"
|
||||
|
||||
[environments.cloud.resources]
|
||||
cpu = 4
|
||||
|
|
|
|||
|
|
@ -320,8 +320,8 @@ memory = "8GB"
|
|||
|---|---|
|
||||
| `run.environment.id` | Environment slug to select. Defaults to `default`. |
|
||||
| `environments.<slug>.provider` | Required provider: `local`, `docker`, or `daytona`. |
|
||||
| `image.docker` | Docker image. Daytona rejects this field. |
|
||||
| `image.dockerfile` | Inline Dockerfile or `{ path = "Dockerfile" }`; Daytona uses it to create or reuse an internally named snapshot. |
|
||||
| `image.docker` | Docker image. Docker runs it directly; Daytona uses it to create or reuse an internally named snapshot. |
|
||||
| `image.dockerfile` | Inline Dockerfile or `{ path = "Dockerfile" }`; Daytona uses it to create or reuse an internally named snapshot. Do not set it with `image.docker`. |
|
||||
| `resources.cpu` / `memory` / `disk` | Best-effort resource hints. Unsupported provider fields warn and continue. |
|
||||
| `network.mode` | `allow_all`, `block`, or `cidr_allow_list`. Local cannot enforce blocked/CIDR networking; Docker cannot enforce CIDR allow-lists. |
|
||||
| `network.allow` | CIDRs for `cidr_allow_list`; entries are validated as CIDRs. |
|
||||
|
|
|
|||
|
|
@ -71,8 +71,9 @@ overwritten.
|
|||
</Note>
|
||||
|
||||
[environments.cloud.image]
|
||||
dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep"
|
||||
# Or keep the Dockerfile next to this TOML file:
|
||||
docker = "rust:1.85-slim-bookworm"
|
||||
# Or replace docker with an inline or path-based Dockerfile:
|
||||
# dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep"
|
||||
# dockerfile = { path = "./Dockerfile" }
|
||||
|
||||
[environments.cloud.resources]
|
||||
|
|
@ -110,7 +111,7 @@ Snapshots let you pre-build an environment image so each run starts with depende
|
|||
|
||||
```toml title="run.toml"
|
||||
[environments.cloud.image]
|
||||
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
|
||||
docker = "node:20-slim"
|
||||
|
||||
[environments.cloud.resources]
|
||||
cpu = 4
|
||||
|
|
@ -118,10 +119,19 @@ memory = 8
|
|||
disk = 20
|
||||
```
|
||||
|
||||
When a run starts with `image.dockerfile`, Fabro computes an internal snapshot name and looks up that snapshot in Daytona. If it doesn't exist, Fabro creates it automatically and polls until it reaches `Active` state (up to 10 minutes). `dockerfile` can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. If the snapshot already exists, it's reused immediately.
|
||||
Set either `image.docker` or `image.dockerfile`. `image.docker` can name any image that Daytona can pull, so a Dockerfile is not required. Use `image.dockerfile` when the image needs extra packages or other build steps:
|
||||
|
||||
```toml title="run.toml"
|
||||
[environments.cloud.image]
|
||||
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
|
||||
```
|
||||
|
||||
Fabro computes an internal snapshot name and looks up that snapshot in Daytona. If it does not exist, Fabro creates it automatically and polls until it reaches `Active` state for up to 30 minutes. A Dockerfile can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. If the snapshot already exists, Fabro reuses it immediately.
|
||||
|
||||
The exact `image.docker` value is part of the snapshot identity. Prefer a digest such as `registry.example.com/team/image@sha256:...` when the image must be reproducible. If a mutable tag moves without its text changing, Fabro continues to reuse the existing snapshot.
|
||||
|
||||
<Note>
|
||||
If no Dockerfile is configured, sandboxes are created from the `daytona-medium` snapshot which includes standard dev tools (git, etc.). To force a new custom snapshot, change the Dockerfile text, for example by adding a comment.
|
||||
If neither image source is configured, sandboxes are created from the `daytona-medium` snapshot, which includes standard dev tools such as Git. To force a new Dockerfile snapshot, change the Dockerfile text, for example by adding a comment.
|
||||
</Note>
|
||||
|
||||
## Private repositories
|
||||
|
|
@ -227,7 +237,7 @@ If doctor reports missing scopes, regenerate the Daytona key with `write:snapsho
|
|||
|
||||
### Custom snapshot did not roll
|
||||
|
||||
Custom Daytona snapshot names are computed from the Dockerfile, resource hints, tenant scope, and Daytona API key. To force a new custom snapshot, change `image.dockerfile` text under the selected `[environments.<slug>.image]`.
|
||||
Custom Daytona snapshot names are computed from the image reference or Dockerfile, resource hints, tenant scope, and Daytona API key. For `image.docker`, use an immutable digest and update it when the image changes. For `image.dockerfile`, change the Dockerfile text under the selected `[environments.<slug>.image]`.
|
||||
|
||||
### "Timed out waiting for snapshot to become active"
|
||||
|
||||
|
|
|
|||
|
|
@ -1110,9 +1110,8 @@ async fn validate_intent_environment(
|
|||
let provider = run_manifest::effective_sandbox_provider(&settings.run);
|
||||
let image = &settings.run.environment.image;
|
||||
let image_incompatible = match provider {
|
||||
SandboxProviderKind::Local => false,
|
||||
SandboxProviderKind::Docker => image.docker.is_none() && image.dockerfile.is_some(),
|
||||
SandboxProviderKind::Daytona => image.docker.is_some(),
|
||||
SandboxProviderKind::Local | SandboxProviderKind::Daytona => false,
|
||||
};
|
||||
let (target_incompatible, detail) = match target {
|
||||
RunTarget::Git(_) => (
|
||||
|
|
|
|||
|
|
@ -117,10 +117,19 @@ pub enum DockerfileSource {
|
|||
Path { path: String },
|
||||
}
|
||||
|
||||
/// Where a custom Daytona snapshot is built from.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub enum DaytonaSnapshotSource {
|
||||
/// A pullable image reference such as `ubuntu:24.04`.
|
||||
Image(String),
|
||||
/// A Dockerfile that Daytona builds into the snapshot.
|
||||
Dockerfile(DockerfileSource),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct DaytonaSnapshotSettings {
|
||||
pub cpu: Option<i32>,
|
||||
pub memory: Option<i32>,
|
||||
pub disk: Option<i32>,
|
||||
pub dockerfile: Option<DockerfileSource>,
|
||||
pub cpu: Option<i32>,
|
||||
pub memory: Option<i32>,
|
||||
pub disk: Option<i32>,
|
||||
pub source: DaytonaSnapshotSource,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ use crate::{
|
|||
|
||||
/// Remediation shown when a Daytona sandbox has no usable Bash.
|
||||
const DAYTONA_BASH_REMEDIATION: &str = "Daytona sandboxes require /bin/bash for every command, with no `sh` fallback. Use the \
|
||||
built-in Daytona snapshot, or a custom snapshot whose Dockerfile installs bash.";
|
||||
built-in Daytona snapshot, or a custom snapshot whose image provides bash.";
|
||||
|
||||
/// Remediation shown when the session transport reaches Bash but never
|
||||
/// completes.
|
||||
|
|
@ -133,7 +133,7 @@ pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[
|
|||
|
||||
pub use crate::config::{
|
||||
DaytonaNetwork, DaytonaSettings as DaytonaConfig,
|
||||
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource,
|
||||
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource,
|
||||
};
|
||||
|
||||
pub mod snapshot_identity {
|
||||
|
|
@ -142,7 +142,7 @@ pub mod snapshot_identity {
|
|||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{DaytonaSnapshotConfig, DockerfileSource};
|
||||
use super::{DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource};
|
||||
|
||||
const IDENTITY_VERSION: u8 = 1;
|
||||
const PROVIDER: &str = "daytona";
|
||||
|
|
@ -150,16 +150,28 @@ pub mod snapshot_identity {
|
|||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// The snapshot source as it appears in the identity manifest. Each
|
||||
/// variant flattens into a single `"<key>": "<value>"` entry.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum SourceManifest<'a> {
|
||||
DockerfileSha256(String),
|
||||
Image(&'a str),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SnapshotManifest<'a> {
|
||||
identity_version: u8,
|
||||
provider: &'static str,
|
||||
tenant: &'static str,
|
||||
dockerfile_sha256: &'a str,
|
||||
cpu: Option<i32>,
|
||||
memory_gb: Option<i32>,
|
||||
disk_gb: Option<i32>,
|
||||
entrypoint: Option<&'static str>,
|
||||
identity_version: u8,
|
||||
provider: &'static str,
|
||||
tenant: &'static str,
|
||||
#[serde(flatten)]
|
||||
source: SourceManifest<'a>,
|
||||
cpu: Option<i32>,
|
||||
memory_gb: Option<i32>,
|
||||
disk_gb: Option<i32>,
|
||||
/// Nothing sets an entrypoint yet. The field stays because removing
|
||||
/// it would rename every existing snapshot under `IDENTITY_VERSION` 1.
|
||||
entrypoint: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub fn snapshot_name(api_key: &str, config: &DaytonaSnapshotConfig) -> crate::Result<String> {
|
||||
|
|
@ -174,29 +186,26 @@ pub mod snapshot_identity {
|
|||
}
|
||||
|
||||
fn canonical_manifest(config: &DaytonaSnapshotConfig) -> crate::Result<Vec<u8>> {
|
||||
let dockerfile = match &config.dockerfile {
|
||||
Some(DockerfileSource::Inline(text)) => text.as_str(),
|
||||
Some(DockerfileSource::Path { .. }) => {
|
||||
let source = match &config.source {
|
||||
DaytonaSnapshotSource::Image(image) => SourceManifest::Image(image),
|
||||
DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(text)) => {
|
||||
SourceManifest::DockerfileSha256(hex::encode(Sha256::digest(text.as_bytes())))
|
||||
}
|
||||
DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { .. }) => {
|
||||
return Err(crate::Error::message(
|
||||
"Daytona snapshot dockerfile path should have been resolved to inline content before sandbox creation",
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Err(crate::Error::message(
|
||||
"Daytona custom snapshots require image.dockerfile",
|
||||
));
|
||||
}
|
||||
};
|
||||
let dockerfile_sha256 = hex::encode(Sha256::digest(dockerfile.as_bytes()));
|
||||
let manifest = SnapshotManifest {
|
||||
identity_version: IDENTITY_VERSION,
|
||||
provider: PROVIDER,
|
||||
tenant: TENANT,
|
||||
dockerfile_sha256: &dockerfile_sha256,
|
||||
cpu: config.cpu,
|
||||
memory_gb: config.memory,
|
||||
disk_gb: config.disk,
|
||||
entrypoint: None,
|
||||
identity_version: IDENTITY_VERSION,
|
||||
provider: PROVIDER,
|
||||
tenant: TENANT,
|
||||
source,
|
||||
cpu: config.cpu,
|
||||
memory_gb: config.memory,
|
||||
disk_gb: config.disk,
|
||||
entrypoint: None,
|
||||
};
|
||||
serde_json::to_vec(&manifest).map_err(|err| {
|
||||
crate::Error::context("Failed to serialize Daytona snapshot identity", err)
|
||||
|
|
@ -204,6 +213,35 @@ pub mod snapshot_identity {
|
|||
}
|
||||
}
|
||||
|
||||
fn create_snapshot_params(
|
||||
name: &str,
|
||||
config: &DaytonaSnapshotConfig,
|
||||
) -> crate::Result<daytona_sdk::CreateSnapshotParams> {
|
||||
let image = match &config.source {
|
||||
DaytonaSnapshotSource::Image(image) => daytona_sdk::ImageSource::Name(image.clone()),
|
||||
DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(dockerfile)) => {
|
||||
daytona_sdk::ImageSource::Custom(daytona_sdk::DockerImage::from_dockerfile(dockerfile))
|
||||
}
|
||||
DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { .. }) => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Snapshot '{name}': dockerfile path should have been resolved to inline content before sandbox creation"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(daytona_sdk::CreateSnapshotParams {
|
||||
name: name.to_string(),
|
||||
image,
|
||||
resources: Some(daytona_sdk::Resources {
|
||||
cpu: config.cpu,
|
||||
memory: config.memory,
|
||||
disk: config.disk,
|
||||
..Default::default()
|
||||
}),
|
||||
entrypoint: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DaytonaKeyCheck {
|
||||
pub key_name: String,
|
||||
|
|
@ -1101,9 +1139,9 @@ impl DaytonaSandbox {
|
|||
|
||||
/// Ensure the named snapshot exists and is active.
|
||||
///
|
||||
/// If the snapshot doesn't exist and a dockerfile is provided, creates it
|
||||
/// and polls until it reaches `Active` state. Returns an error if the
|
||||
/// snapshot is in a terminal failure state.
|
||||
/// If the snapshot doesn't exist and an image source is provided, creates
|
||||
/// it and polls until it reaches `Active` state. Returns an error if
|
||||
/// the snapshot is in a terminal failure state.
|
||||
async fn ensure_snapshot(
|
||||
&self,
|
||||
name: &str,
|
||||
|
|
@ -1131,37 +1169,11 @@ impl DaytonaSandbox {
|
|||
}
|
||||
}
|
||||
Err(daytona_sdk::DaytonaError::NotFound { .. }) => {
|
||||
let dockerfile = match &snap_cfg.dockerfile {
|
||||
Some(DockerfileSource::Inline(s)) => s.as_str(),
|
||||
Some(DockerfileSource::Path { .. }) => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Snapshot '{name}': dockerfile path should have been resolved to inline content before sandbox creation"
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Snapshot '{name}' does not exist and no dockerfile provided to create it"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
self.emit(SandboxEvent::SnapshotCreating {
|
||||
name: name.to_string(),
|
||||
});
|
||||
|
||||
let params = daytona_sdk::CreateSnapshotParams {
|
||||
name: name.to_string(),
|
||||
image: daytona_sdk::ImageSource::Custom(
|
||||
daytona_sdk::DockerImage::from_dockerfile(dockerfile),
|
||||
),
|
||||
resources: Some(daytona_sdk::Resources {
|
||||
cpu: snap_cfg.cpu,
|
||||
memory: snap_cfg.memory,
|
||||
disk: snap_cfg.disk,
|
||||
..Default::default()
|
||||
}),
|
||||
entrypoint: None,
|
||||
};
|
||||
let params = create_snapshot_params(name, snap_cfg)?;
|
||||
self.client.snapshot.create(¶ms).await.map_err(|e| {
|
||||
crate::Error::context(format!("Failed to create snapshot '{name}'"), e)
|
||||
})?;
|
||||
|
|
@ -1466,12 +1478,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
});
|
||||
let init_start = Instant::now();
|
||||
|
||||
let params = if let Some(snap_cfg) = self
|
||||
.config
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.filter(|snapshot| snapshot.dockerfile.is_some())
|
||||
{
|
||||
let params = if let Some(snap_cfg) = self.config.snapshot.as_ref() {
|
||||
let api_key = self.api_key.as_deref().ok_or_else(|| {
|
||||
self.fail_init(
|
||||
init_start,
|
||||
|
|
@ -3650,10 +3657,10 @@ mod tests {
|
|||
#[test]
|
||||
fn computed_snapshot_identity_is_deterministic_and_keyed() {
|
||||
let config = DaytonaSnapshotConfig {
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
dockerfile: Some(DockerfileSource::Inline(
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(
|
||||
"FROM ubuntu:24.04\nRUN apt-get update".to_string(),
|
||||
)),
|
||||
};
|
||||
|
|
@ -3663,6 +3670,7 @@ mod tests {
|
|||
let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &config).unwrap();
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first, "fabro-e607185f-c7ab-88c9-bf9d-d70addba9298");
|
||||
assert_ne!(first, rotated_key);
|
||||
let uuid = first
|
||||
.strip_prefix("fabro-")
|
||||
|
|
@ -3675,16 +3683,18 @@ mod tests {
|
|||
#[test]
|
||||
fn computed_snapshot_identity_changes_for_generation_inputs() {
|
||||
let base = DaytonaSnapshotConfig {
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
dockerfile: Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string())),
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(
|
||||
"FROM ubuntu:24.04".to_string(),
|
||||
)),
|
||||
};
|
||||
let base_name = snapshot_identity::snapshot_name("dtn_secret", &base).unwrap();
|
||||
|
||||
let cases = [
|
||||
DaytonaSnapshotConfig {
|
||||
dockerfile: Some(DockerfileSource::Inline(
|
||||
source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(
|
||||
"FROM ubuntu:24.04\n# roll cache".to_string(),
|
||||
)),
|
||||
..base.clone()
|
||||
|
|
@ -3712,10 +3722,10 @@ mod tests {
|
|||
#[test]
|
||||
fn computed_snapshot_identity_excludes_raw_dockerfile_and_key_material() {
|
||||
let config = DaytonaSnapshotConfig {
|
||||
cpu: None,
|
||||
memory: None,
|
||||
disk: None,
|
||||
dockerfile: Some(DockerfileSource::Inline(
|
||||
cpu: None,
|
||||
memory: None,
|
||||
disk: None,
|
||||
source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(
|
||||
"FROM private.example.com/secret-image\nRUN echo raw-secret".to_string(),
|
||||
)),
|
||||
};
|
||||
|
|
@ -3728,14 +3738,57 @@ mod tests {
|
|||
assert!(!name.contains("dtn_super_secret_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_snapshot_identity_changes_for_image_reference() {
|
||||
let config = DaytonaSnapshotConfig {
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
source: DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()),
|
||||
};
|
||||
let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap();
|
||||
let changed = snapshot_identity::snapshot_name("dtn_secret", &DaytonaSnapshotConfig {
|
||||
source: DaytonaSnapshotSource::Image("ubuntu:24.10".to_string()),
|
||||
..config
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first, "fabro-5d23a023-d7ff-8d68-b3ca-e6286f4211d9");
|
||||
assert_ne!(first, changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_creation_uses_named_image_source() {
|
||||
let config = DaytonaSnapshotConfig {
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
source: DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()),
|
||||
};
|
||||
|
||||
let params = create_snapshot_params("fabro-test", &config).unwrap();
|
||||
|
||||
assert_eq!(params.name, "fabro-test");
|
||||
assert!(matches!(
|
||||
params.image,
|
||||
daytona_sdk::ImageSource::Name(ref image) if image == "ubuntu:24.04"
|
||||
));
|
||||
let resources = params.resources.expect("resources should be configured");
|
||||
assert_eq!(resources.cpu, Some(2));
|
||||
assert_eq!(resources.memory, Some(4));
|
||||
assert_eq!(resources.disk, Some(10));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_snapshot_uses_computed_snapshot_name_for_daytona_api_calls() {
|
||||
let api_key = "dtn_secret";
|
||||
let snapshot = DaytonaSnapshotConfig {
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
dockerfile: Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string())),
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(
|
||||
"FROM ubuntu:24.04".to_string(),
|
||||
)),
|
||||
};
|
||||
let computed_name = snapshot_identity::snapshot_name(api_key, &snapshot).unwrap();
|
||||
let server = MockServer::start_async().await;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ use fabro_types::settings::run::{
|
|||
|
||||
#[cfg(feature = "daytona")]
|
||||
use crate::config::{
|
||||
DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource,
|
||||
DaytonaNetwork, DaytonaSnapshotSettings, DaytonaSnapshotSource,
|
||||
DockerfileSource as SandboxDockerfileSource,
|
||||
};
|
||||
#[cfg(feature = "daytona")]
|
||||
use crate::daytona::DaytonaConfig;
|
||||
|
|
@ -28,42 +29,48 @@ pub fn daytona_config_from_environment(
|
|||
settings: &RunEnvironmentSettings,
|
||||
clone: &RunCloneSettings,
|
||||
) -> DaytonaConfig {
|
||||
// fabro-config rejects Daytona environments that set both image.docker
|
||||
// and image.dockerfile. If both still arrive here, the image wins, which
|
||||
// matches how the Docker provider treats the pair.
|
||||
let source = match (&settings.image.docker, &settings.image.dockerfile) {
|
||||
(Some(image), _) => Some(DaytonaSnapshotSource::Image(image.clone())),
|
||||
(None, Some(ResolvedDockerfileSource::Inline(text))) => Some(
|
||||
DaytonaSnapshotSource::Dockerfile(SandboxDockerfileSource::Inline(text.clone())),
|
||||
),
|
||||
(None, Some(ResolvedDockerfileSource::Path { path })) => Some(
|
||||
DaytonaSnapshotSource::Dockerfile(SandboxDockerfileSource::Path { path: path.clone() }),
|
||||
),
|
||||
(None, None) => None,
|
||||
};
|
||||
let snapshot = source.map(|source| DaytonaSnapshotSettings {
|
||||
cpu: settings.resources.cpu,
|
||||
memory: settings
|
||||
.resources
|
||||
.memory
|
||||
.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
disk: settings
|
||||
.resources
|
||||
.disk
|
||||
.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
source,
|
||||
});
|
||||
|
||||
DaytonaConfig {
|
||||
auto_stop_interval: settings
|
||||
.lifecycle
|
||||
.auto_stop
|
||||
.map(|duration| duration_to_minutes_i32(duration.as_std())),
|
||||
labels: (!settings.labels.is_empty()).then(|| settings.labels.clone()),
|
||||
snapshot: settings.image.dockerfile.as_ref().map(|dockerfile| {
|
||||
DaytonaSnapshotSettings {
|
||||
cpu: settings.resources.cpu,
|
||||
memory: settings
|
||||
.resources
|
||||
.memory
|
||||
.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
disk: settings
|
||||
.resources
|
||||
.disk
|
||||
.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
dockerfile: Some(match dockerfile {
|
||||
ResolvedDockerfileSource::Inline(text) => {
|
||||
SandboxDockerfileSource::Inline(text.clone())
|
||||
}
|
||||
ResolvedDockerfileSource::Path { path } => {
|
||||
SandboxDockerfileSource::Path { path: path.clone() }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
network: Some(match settings.network.mode {
|
||||
labels: (!settings.labels.is_empty()).then(|| settings.labels.clone()),
|
||||
snapshot,
|
||||
network: Some(match settings.network.mode {
|
||||
EnvironmentNetworkMode::Block => DaytonaNetwork::Block,
|
||||
EnvironmentNetworkMode::AllowAll => DaytonaNetwork::AllowAll,
|
||||
EnvironmentNetworkMode::CidrAllowList => {
|
||||
DaytonaNetwork::AllowList(settings.network.allow.clone())
|
||||
}
|
||||
}),
|
||||
clone_depth: clone.depth_limit(),
|
||||
skip_clone: !clone.enabled,
|
||||
clone_depth: clone.depth_limit(),
|
||||
skip_clone: !clone.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -243,4 +250,21 @@ mod tests {
|
|||
);
|
||||
assert!(!missing.exists());
|
||||
}
|
||||
|
||||
#[cfg(feature = "daytona")]
|
||||
#[test]
|
||||
fn daytona_config_maps_docker_image_to_snapshot() {
|
||||
let mut settings = run_environment(EnvironmentProvider::Daytona);
|
||||
settings.image.docker = Some("ubuntu:24.04".to_string());
|
||||
settings.resources.cpu = Some(2);
|
||||
|
||||
let config = daytona_config_from_environment(&settings, &RunCloneSettings::default());
|
||||
let snapshot = config.snapshot.expect("image should configure a snapshot");
|
||||
|
||||
assert_eq!(
|
||||
snapshot.source,
|
||||
DaytonaSnapshotSource::Image("ubuntu:24.04".to_string())
|
||||
);
|
||||
assert_eq!(snapshot.cpu, Some(2));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -403,12 +403,15 @@ async fn daytona_snapshot_sandbox() {
|
|||
let config = DaytonaConfig {
|
||||
auto_stop_interval: Some(60),
|
||||
snapshot: Some(DaytonaSnapshotConfig {
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
dockerfile: Some(fabro_sandbox::daytona::DockerfileSource::Inline(
|
||||
"FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(),
|
||||
)),
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
source: fabro_sandbox::daytona::DaytonaSnapshotSource::Dockerfile(
|
||||
fabro_sandbox::daytona::DockerfileSource::Inline(
|
||||
"FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep"
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
}),
|
||||
..DaytonaConfig::default()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ fn resolve_environment_fields(
|
|||
labels: layer.labels.clone().into_inner(),
|
||||
env: layer.env.clone().into_inner(),
|
||||
};
|
||||
validate_daytona_image_settings(&environment, path, errors);
|
||||
environment
|
||||
}
|
||||
|
||||
|
|
@ -201,19 +200,6 @@ fn dockerfile_source(dockerfile: &EnvironmentDockerfileLayer) -> DockerfileSourc
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_daytona_image_settings(
|
||||
environment: &EnvironmentSettings,
|
||||
path: &str,
|
||||
errors: &mut Vec<ResolveError>,
|
||||
) {
|
||||
if environment.provider == EnvironmentProvider::Daytona && environment.image.docker.is_some() {
|
||||
errors.push(ResolveError::Invalid {
|
||||
path: format!("{path}.image"),
|
||||
reason: "daytona environments do not support image.docker; use image.dockerfile for custom snapshots".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_provider_capabilities(
|
||||
environment: &EnvironmentSettings,
|
||||
path: &str,
|
||||
|
|
@ -242,6 +228,15 @@ fn validate_provider_capabilities(
|
|||
});
|
||||
}
|
||||
}
|
||||
EnvironmentProvider::Daytona => {}
|
||||
EnvironmentProvider::Daytona => {
|
||||
if environment.image.docker.is_some() && environment.image.dockerfile.is_some() {
|
||||
errors.push(ResolveError::Invalid {
|
||||
path: format!("{path}.image"),
|
||||
reason: "daytona environments accept either image.docker or image.dockerfile, \
|
||||
not both"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -658,8 +658,8 @@ dockerfile = { path = "Dockerfile" }
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_image_docker_errors() {
|
||||
let err = workflow_settings_from_toml_with_catalog(
|
||||
fn daytona_image_docker_resolves() {
|
||||
let settings = workflow_settings_from_toml_with_catalog(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
|
|
@ -674,12 +674,41 @@ provider = "daytona"
|
|||
docker = "ubuntu:24.04"
|
||||
"#,
|
||||
)
|
||||
.expect_err("daytona should reject docker image selection");
|
||||
.expect("daytona should accept docker image selection")
|
||||
.run;
|
||||
|
||||
assert_eq!(settings.environment.provider, EnvironmentProvider::Daytona);
|
||||
assert_eq!(
|
||||
settings.environment.image.docker.as_deref(),
|
||||
Some("ubuntu:24.04")
|
||||
);
|
||||
assert!(settings.environment.image.dockerfile.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_rejects_docker_image_and_dockerfile_together() {
|
||||
let err = workflow_settings_from_toml_with_catalog(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.environment]
|
||||
id = "cloud"
|
||||
"#,
|
||||
r#"
|
||||
[environments.cloud]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
docker = "ubuntu:24.04"
|
||||
dockerfile = "FROM ubuntu:24.04"
|
||||
"#,
|
||||
)
|
||||
.expect_err("daytona should reject two snapshot sources");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("image.docker") && message.contains("daytona"),
|
||||
"expected daytona image.docker diagnostic, got: {message}"
|
||||
message.contains("image.docker") && message.contains("image.dockerfile"),
|
||||
"expected mutually exclusive image diagnostic, got: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue