mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
feat(sandbox): secure daytona snapshot names (#429)
## Summary Secures Daytona custom snapshot creation by removing user-controlled snapshot/image references and replacing them with deterministic names Fabro computes internally. Docker image selection now uses `image.docker`, while Daytona only accepts `image.dockerfile` for custom snapshots and continues to use `daytona-medium` when no Dockerfile is configured. ## Changes - Replaces public `image.ref` config/API shape with Docker-specific `image.docker` across Rust settings, OpenAPI, generated TypeScript client, docs, defaults, examples, and web samples. - Adds Daytona snapshot identity generation using HMAC-SHA256 over a canonical manifest keyed by the Daytona API key, producing `fabro-<uuid>` snapshot names without exposing Dockerfile text or key material. - Routes Daytona custom Dockerfiles, including devcontainer-generated Dockerfiles, through the same computed identity path before calling Daytona snapshot APIs. - Updates sandbox initialization events and store projections so initialized run state can show the resolved image and computed Daytona snapshot after startup. - Updates legacy config migration behavior so Docker image refs map to `image.docker`, while Daytona legacy snapshot names are not preserved. ## Breaking Changes - `image.ref` is no longer accepted in new environment config. - Docker environments should use `image.docker` for image selection. - Daytona environments reject `image.docker`; use `image.dockerfile` to request a custom computed snapshot. ## Verification - `cargo build -p fabro-api` - `cd lib/packages/fabro-api-client && bun run generate` - `cd lib/packages/fabro-api-client && bun run typecheck` - `cd apps/fabro-web && bun run typecheck` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` - `ulimit -n 4096 && cargo nextest run --no-fail-fast -p fabro-cli -p fabro-config -p fabro-sandbox -p fabro-workflow -p fabro-store -p fabro-server -p fabro-api` - `cargo insta pending-snapshots` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
parent
b1bd2f522c
commit
ec1b3f2084
45 changed files with 564 additions and 272 deletions
|
|
@ -17,7 +17,6 @@ auto_stop = "30m"
|
|||
repo = "fabro-sh/fabro"
|
||||
|
||||
[environments.fabro-dev.image]
|
||||
ref = "fabro-v12"
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
|
||||
[environments.fabro-dev.resources]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,3 @@ id = "daytona-medium"
|
|||
|
||||
[environments.daytona-medium]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.daytona-medium.image]
|
||||
ref = "daytona-medium"
|
||||
|
|
|
|||
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -2238,12 +2238,15 @@ dependencies = [
|
|||
"futures-util",
|
||||
"git2",
|
||||
"glob",
|
||||
"hex",
|
||||
"hmac",
|
||||
"httpmock",
|
||||
"rand 0.9.4",
|
||||
"reqwest-middleware",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shlex",
|
||||
"strum",
|
||||
"tar",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function sampleSettings({
|
|||
const environmentId = labels.project ?? snapshot;
|
||||
const environment = {
|
||||
provider: "daytona",
|
||||
image: { ref: snapshot, dockerfile: null },
|
||||
image: { docker: null, dockerfile: null },
|
||||
resources: {
|
||||
cpu,
|
||||
memory: `${memoryGb}GB`,
|
||||
|
|
|
|||
|
|
@ -255,9 +255,6 @@ id = "cloud"
|
|||
[environments.cloud]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
ref = "daytona-medium"
|
||||
|
||||
[run.artifacts]
|
||||
include = ["screenshots/**"]
|
||||
|
||||
|
|
|
|||
|
|
@ -12347,9 +12347,9 @@ components:
|
|||
|
||||
EnvironmentImageSettings:
|
||||
type: object
|
||||
required: [ref, dockerfile]
|
||||
required: [docker, dockerfile]
|
||||
properties:
|
||||
ref:
|
||||
docker:
|
||||
type: ["string", "null"]
|
||||
dockerfile:
|
||||
oneOf:
|
||||
|
|
|
|||
|
|
@ -278,7 +278,6 @@ id = "python-dev"
|
|||
provider = "daytona"
|
||||
|
||||
[environments.python-dev.image]
|
||||
ref = "python-dev"
|
||||
dockerfile = "FROM python:3.12-slim\nRUN apt-get update && apt-get install -y git libncurses-dev"
|
||||
|
||||
[environments.python-dev.resources]
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ id = "dev"
|
|||
provider = "docker"
|
||||
|
||||
[environments.dev.image]
|
||||
ref = "ghcr.io/acme/project-devcontainer:latest"
|
||||
docker = "ghcr.io/acme/project-devcontainer:latest"
|
||||
```
|
||||
|
||||
For Daytona snapshot creation, provide a Dockerfile path on the selected environment:
|
||||
|
|
@ -28,6 +28,5 @@ id = "cloud-dev"
|
|||
provider = "daytona"
|
||||
|
||||
[environments.cloud-dev.image]
|
||||
ref = "project-dev"
|
||||
dockerfile = { path = ".devcontainer/Dockerfile" }
|
||||
```
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ id = "fabro-dev"
|
|||
provider = "daytona" # local | docker | daytona
|
||||
|
||||
[environments.fabro-dev.image]
|
||||
ref = "fabro-v11" # Docker image or Daytona snapshot name
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
|
||||
[environments.fabro-dev.resources]
|
||||
|
|
@ -99,7 +98,7 @@ id = "default"
|
|||
provider = "docker"
|
||||
|
||||
[environments.default.image]
|
||||
ref = "buildpack-deps:noble"
|
||||
docker = "buildpack-deps:noble"
|
||||
|
||||
[environments.default.resources]
|
||||
cpu = 2
|
||||
|
|
@ -114,8 +113,8 @@ stop_on_terminal = true
|
|||
|
||||
| Environment field | Local | Docker | Daytona |
|
||||
|---|---|---|---|
|
||||
| `image.ref` | Ignored | Docker image | Snapshot name |
|
||||
| `image.dockerfile` | Ignored | Warning; ignored | Snapshot Dockerfile; requires `image.ref` |
|
||||
| `image.docker` | Ignored | Docker image | Error |
|
||||
| `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 |
|
||||
| `resources.disk` | Warning; ignored | Warning; ignored | Snapshot disk |
|
||||
|
|
@ -143,7 +142,7 @@ Fabro hard-errors if a local environment asks for blocked or CIDR-restricted net
|
|||
|
||||
## Docker
|
||||
|
||||
Docker runs tools inside a container created from `image.ref`. Docker is the built-in default provider.
|
||||
Docker runs tools inside a container created from `image.docker`. Docker is the built-in default provider.
|
||||
|
||||
```toml
|
||||
[run.environment]
|
||||
|
|
@ -153,7 +152,7 @@ id = "ci"
|
|||
provider = "docker"
|
||||
|
||||
[environments.ci.image]
|
||||
ref = "buildpack-deps:noble"
|
||||
docker = "buildpack-deps:noble"
|
||||
|
||||
[environments.ci.resources]
|
||||
cpu = 2
|
||||
|
|
@ -167,7 +166,7 @@ Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fa
|
|||
|
||||
## Daytona
|
||||
|
||||
Daytona runs tools in a cloud sandbox. `image.ref` is the snapshot name. If `image.dockerfile` is set and the snapshot does not exist, Fabro creates the snapshot; `image.ref` is required so the snapshot has a name.
|
||||
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.
|
||||
|
||||
```toml
|
||||
[run.environment]
|
||||
|
|
@ -177,7 +176,6 @@ id = "cloud"
|
|||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
ref = "rust-dev"
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
|
||||
[environments.cloud.resources]
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ project = "fabro"
|
|||
env = "ci"
|
||||
|
||||
[environments.cloud.image]
|
||||
ref = "node-20"
|
||||
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
|
||||
|
||||
[environments.cloud.resources]
|
||||
|
|
@ -248,7 +247,7 @@ id = "ci"
|
|||
provider = "docker" # local | docker | daytona
|
||||
|
||||
[environments.ci.image]
|
||||
ref = "buildpack-deps:noble"
|
||||
docker = "buildpack-deps:noble"
|
||||
|
||||
[environments.ci.resources]
|
||||
cpu = 2
|
||||
|
|
@ -274,8 +273,8 @@ memory = "8GB"
|
|||
|---|---|
|
||||
| `run.environment.id` | Environment slug to select. Defaults to `default`. |
|
||||
| `environments.<slug>.provider` | Required provider: `local`, `docker`, or `daytona`. |
|
||||
| `image.ref` | Docker image or Daytona snapshot name. |
|
||||
| `image.dockerfile` | Inline Dockerfile or `{ path = "Dockerfile" }`; for Daytona, requires `image.ref`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
|
@ -558,7 +557,7 @@ id = "cloud"
|
|||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
ref = "my-project-snapshot"
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
```
|
||||
|
||||
Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), TOML `run.inputs` tables replace wholesale, CLI input flags merge per key at highest precedence, environment `env` and `labels` merge by key, environment `volumes` replace as a whole list, and `run.prepare.steps` replaces whole-list.
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ overwritten.
|
|||
</Note>
|
||||
|
||||
[environments.cloud.image]
|
||||
ref = "rust-dev"
|
||||
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:
|
||||
# dockerfile = { path = "./Dockerfile" }
|
||||
|
|
@ -111,7 +110,6 @@ Snapshots let you pre-build an environment image so each run starts with depende
|
|||
|
||||
```toml title="run.toml"
|
||||
[environments.cloud.image]
|
||||
ref = "my-snapshot"
|
||||
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
|
||||
|
||||
[environments.cloud.resources]
|
||||
|
|
@ -120,10 +118,10 @@ memory = 8
|
|||
disk = 20
|
||||
```
|
||||
|
||||
When a run starts with a snapshot configured, Fabro looks up the snapshot by name. If it doesn't exist and a `dockerfile` is provided, 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.
|
||||
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.
|
||||
|
||||
<Note>
|
||||
If a snapshot is configured by name but doesn't exist and no `dockerfile` is provided, the run fails immediately. If no snapshot is configured at all, sandboxes are created from the `daytona-medium` snapshot which includes standard dev tools (git, etc.).
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## Private repositories
|
||||
|
|
@ -214,9 +212,9 @@ The `DAYTONA_API_KEY` vault secret is missing, invalid, or missing the required
|
|||
|
||||
If doctor reports missing scopes, regenerate the Daytona key with `write:snapshots`, `delete:snapshots`, `write:sandboxes`, and `delete:sandboxes`, then save it again with `fabro secret set DAYTONA_API_KEY`.
|
||||
|
||||
### "Snapshot does not exist and no dockerfile provided"
|
||||
### Custom snapshot did not roll
|
||||
|
||||
The run config references a snapshot name that doesn't exist on Daytona, and no `dockerfile` is provided to create it. Either create the snapshot manually in the Daytona dashboard or add `image.dockerfile` under the selected `[environments.<slug>.image]`.
|
||||
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]`.
|
||||
|
||||
### "Timed out waiting for snapshot to become active"
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,35 @@ fn run_event_round_trips_run_created_with_web_url() {
|
|||
assert_run_event_round_trip(value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_event_round_trips_sandbox_initialized_image_and_snapshot() {
|
||||
assert_run_event_round_trip(json!({
|
||||
"id": "evt_sandbox_initialized",
|
||||
"ts": "2026-04-29T12:00:00Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "sandbox.initialized",
|
||||
"properties": {
|
||||
"provider": "daytona",
|
||||
"id": "fabro-run-sandbox",
|
||||
"working_directory": "/home/daytona/workspace",
|
||||
"snapshot": "fabro-11111111-2222-8333-8444-555555555555"
|
||||
}
|
||||
}));
|
||||
|
||||
assert_run_event_round_trip(json!({
|
||||
"id": "evt_sandbox_initialized_docker",
|
||||
"ts": "2026-04-29T12:01:00Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "sandbox.initialized",
|
||||
"properties": {
|
||||
"provider": "docker",
|
||||
"id": "container-id",
|
||||
"working_directory": "/workspace",
|
||||
"image": "ubuntu:24.04"
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_event_round_trips_run_interrupt() {
|
||||
let value = json!({
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ approval = "auto"
|
|||
assert_eq!(json["run"]["goal"]["type"], "inline");
|
||||
assert_eq!(json["run"]["goal"]["value"], "Ship it");
|
||||
assert_eq!(json["run"]["execution"]["approval"], "auto");
|
||||
assert_eq!(
|
||||
json["run"]["environment"]["image"]["docker"],
|
||||
"buildpack-deps:noble"
|
||||
);
|
||||
assert!(json["run"]["environment"]["image"].get("ref").is_none());
|
||||
|
||||
let round_trip: ApiWorkflowSettings =
|
||||
serde_json::from_value(json).expect("workflow settings should deserialize");
|
||||
|
|
|
|||
|
|
@ -746,6 +746,8 @@ mod tests {
|
|||
repos_root: None,
|
||||
primary_repo_path: None,
|
||||
primary_repo_link: None,
|
||||
image: None,
|
||||
snapshot: None,
|
||||
},
|
||||
agent_event("code", AgentEvent::ToolCallStarted {
|
||||
tool_name: "read_file".into(),
|
||||
|
|
@ -1128,6 +1130,8 @@ mod tests {
|
|||
repos_root: None,
|
||||
primary_repo_path: None,
|
||||
primary_repo_link: None,
|
||||
image: None,
|
||||
snapshot: None,
|
||||
});
|
||||
emit(
|
||||
&mut ui,
|
||||
|
|
|
|||
|
|
@ -937,8 +937,8 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"env": {},
|
||||
"id": "local",
|
||||
"image": {
|
||||
"dockerfile": null,
|
||||
"ref": null
|
||||
"docker": null,
|
||||
"dockerfile": null
|
||||
},
|
||||
"labels": {},
|
||||
"lifecycle": {
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ fn inspect_resolves_selector_via_server_endpoint() {
|
|||
"id": "default",
|
||||
"provider": "local",
|
||||
"image": {
|
||||
"ref": null,
|
||||
"docker": null,
|
||||
"dockerfile": null
|
||||
},
|
||||
"resources": {
|
||||
|
|
|
|||
|
|
@ -64,8 +64,14 @@ pub(crate) fn migrate_settings_path(
|
|||
))
|
||||
})?;
|
||||
|
||||
let environment_id = layer
|
||||
.run
|
||||
.as_ref()
|
||||
.and_then(|run| run.environment.as_ref())
|
||||
.and_then(|environment| environment.id.as_deref())
|
||||
.unwrap_or("default");
|
||||
let warning = format!(
|
||||
"Migrated legacy [run.sandbox] settings in {} to [run.environment] and [environments.default]. Backup written to {}. {REMOVAL_NOTE}",
|
||||
"Migrated legacy [run.sandbox] settings in {} to [run.environment] and [environments.{environment_id}]. Backup written to {}. {REMOVAL_NOTE}",
|
||||
path.display(),
|
||||
backup_path.display()
|
||||
);
|
||||
|
|
@ -88,7 +94,7 @@ fn migrate_contents(original_contents: &str, path: &Path) -> Result<Option<Strin
|
|||
}
|
||||
if has_new_environment_config(&doc) {
|
||||
return Err(Error::other(format!(
|
||||
"Legacy [run.sandbox] settings in {} could not be auto-migrated because the file already contains [run.environment] or [environments.default]. Remove one config style and retry.",
|
||||
"Legacy [run.sandbox] settings in {} could not be auto-migrated because the file already contains [run.environment] or [environments]. Remove one config style and retry.",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
|
@ -117,12 +123,8 @@ fn has_new_environment_config(doc: &DocumentMut) -> bool {
|
|||
.and_then(Item::as_table)
|
||||
.and_then(|run| run.get("environment"))
|
||||
.is_some();
|
||||
let has_default_environment = doc
|
||||
.get("environments")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|envs| envs.get("default"))
|
||||
.is_some();
|
||||
has_run_environment || has_default_environment
|
||||
let has_environment_catalog = doc.get("environments").and_then(Item::as_table).is_some();
|
||||
has_run_environment || has_environment_catalog
|
||||
}
|
||||
|
||||
fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> {
|
||||
|
|
@ -185,10 +187,14 @@ fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationF
|
|||
ensure_table(doc.as_table_mut(), &["run", "clone"])["enabled"] =
|
||||
Item::Value(Value::from(false));
|
||||
}
|
||||
let environment_id = match active_provider {
|
||||
Some(EnvironmentProvider::Daytona) => "daytona",
|
||||
_ => "default",
|
||||
};
|
||||
ensure_table(doc.as_table_mut(), &["run", "environment"])["id"] =
|
||||
Item::Value(Value::from("default"));
|
||||
Item::Value(Value::from(environment_id));
|
||||
|
||||
let env = ensure_table(doc.as_table_mut(), &["environments", "default"]);
|
||||
let env = ensure_table(doc.as_table_mut(), &["environments", environment_id]);
|
||||
if let Some(p) = provider_str {
|
||||
env["provider"] = Item::Value(Value::from(p));
|
||||
}
|
||||
|
|
@ -271,7 +277,7 @@ fn migrate_daytona_snapshot(snapshot_item: &Item, env: &mut Table, unsupported:
|
|||
|
||||
for (key, item) in snapshot {
|
||||
match key {
|
||||
"name" => ensure_table(env, &["image"])["ref"] = item.clone(),
|
||||
"name" => {}
|
||||
"cpu" => ensure_table(env, &["resources"])["cpu"] = item.clone(),
|
||||
"memory" => ensure_table(env, &["resources"])["memory"] = item.clone(),
|
||||
"disk" => ensure_table(env, &["resources"])["disk"] = item.clone(),
|
||||
|
|
@ -301,7 +307,7 @@ fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec<String
|
|||
unsupported.push("run.sandbox.docker.skip_clone".to_string());
|
||||
}
|
||||
}
|
||||
"image" => ensure_table(env, &["image"])["ref"] = item.clone(),
|
||||
"image" => ensure_table(env, &["image"])["docker"] = item.clone(),
|
||||
"memory_limit" => ensure_table(env, &["resources"])["memory"] = item.clone(),
|
||||
"cpu_quota" => {
|
||||
if let Some(cpu_quota) = item.as_integer() {
|
||||
|
|
@ -488,7 +494,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn provider_only_daytona_config_migrates_to_default_environment() {
|
||||
fn provider_only_daytona_config_migrates_to_daytona_environment() {
|
||||
let migrated = migrate(
|
||||
r#"
|
||||
_version = 1
|
||||
|
|
@ -505,10 +511,10 @@ provider = "daytona"
|
|||
.expect("migrated settings should resolve")
|
||||
.run;
|
||||
|
||||
assert_eq!(resolved.environment.id, "default");
|
||||
assert_eq!(resolved.environment.id, "daytona");
|
||||
assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona);
|
||||
assert!(migrated.contains("[run.environment]"));
|
||||
assert!(migrated.contains("[environments.default]"));
|
||||
assert!(migrated.contains("[environments.daytona]"));
|
||||
assert!(!migrated.contains("[run.sandbox]"));
|
||||
}
|
||||
|
||||
|
|
@ -561,7 +567,9 @@ subpath = "agents"
|
|||
.run
|
||||
.environment;
|
||||
|
||||
assert_eq!(resolved.image.reference.as_deref(), Some("fabro-v11"));
|
||||
assert_eq!(resolved.id, "daytona");
|
||||
assert_eq!(resolved.image.docker.as_deref(), None);
|
||||
assert!(resolved.image.dockerfile.is_some());
|
||||
assert_eq!(resolved.resources.cpu, Some(8));
|
||||
assert_eq!(
|
||||
resolved.resources.memory.map(|size| size.as_bytes()),
|
||||
|
|
@ -619,7 +627,7 @@ cpu_quota = 200000
|
|||
|
||||
assert_eq!(resolved.provider, EnvironmentProvider::Docker);
|
||||
assert_eq!(
|
||||
resolved.image.reference.as_deref(),
|
||||
resolved.image.docker.as_deref(),
|
||||
Some("buildpack-deps:noble")
|
||||
);
|
||||
assert_eq!(resolved.resources.cpu, Some(2));
|
||||
|
|
@ -674,7 +682,8 @@ provider = "daytona"
|
|||
|
||||
assert_eq!(backup, original);
|
||||
assert!(rewritten.contains("[run.environment]"));
|
||||
assert!(rewritten.contains("[environments.default]"));
|
||||
assert!(rewritten.contains("[environments.daytona]"));
|
||||
assert!(report.warning.contains("[environments.daytona]"));
|
||||
assert!(report.warning.contains("temporary compatibility migration"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ id = "default"
|
|||
provider = "docker"
|
||||
|
||||
[environments.default.image]
|
||||
ref = "buildpack-deps:noble"
|
||||
docker = "buildpack-deps:noble"
|
||||
|
||||
[environments.default.resources]
|
||||
cpu = 2
|
||||
|
|
@ -47,7 +47,7 @@ provider = "local"
|
|||
provider = "docker"
|
||||
|
||||
[environments.docker.image]
|
||||
ref = "buildpack-deps:noble"
|
||||
docker = "buildpack-deps:noble"
|
||||
|
||||
[environments.docker.resources]
|
||||
cpu = 2
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ impl RunEnvironmentLayer {
|
|||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EnvironmentImageLayer {
|
||||
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
|
||||
pub reference: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub docker: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dockerfile: Option<EnvironmentDockerfileLayer>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ fn resolve_environment_layer(
|
|||
volumes: resolve_volumes(layer.volumes.as_deref()),
|
||||
env: layer.env.clone().into_inner(),
|
||||
};
|
||||
validate_daytona_snapshot_name(&environment, path, errors);
|
||||
validate_daytona_image_settings(&environment, path, errors);
|
||||
environment
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ fn resolve_image(layer: Option<&EnvironmentImageLayer>) -> EnvironmentImageSetti
|
|||
return EnvironmentImageSettings::default();
|
||||
};
|
||||
EnvironmentImageSettings {
|
||||
reference: layer.reference.clone(),
|
||||
docker: layer.docker.clone(),
|
||||
dockerfile: layer.dockerfile.as_ref().map(dockerfile_source),
|
||||
}
|
||||
}
|
||||
|
|
@ -179,19 +179,15 @@ fn dockerfile_source(dockerfile: &EnvironmentDockerfileLayer) -> DockerfileSourc
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_daytona_snapshot_name(
|
||||
fn validate_daytona_image_settings(
|
||||
environment: &EnvironmentSettings,
|
||||
path: &str,
|
||||
errors: &mut Vec<ResolveError>,
|
||||
) {
|
||||
if environment.provider == EnvironmentProvider::Daytona
|
||||
&& environment.image.dockerfile.is_some()
|
||||
&& environment.image.reference.is_none()
|
||||
{
|
||||
if environment.provider == EnvironmentProvider::Daytona && environment.image.docker.is_some() {
|
||||
errors.push(ResolveError::Invalid {
|
||||
path: format!("{path}.image"),
|
||||
reason: "daytona environments with image.dockerfile must also set image.ref"
|
||||
.to_string(),
|
||||
reason: "daytona environments do not support image.docker; use image.dockerfile for custom snapshots".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ fn resolves_run_defaults_from_empty_settings() {
|
|||
assert_eq!(settings.environment.id, "default");
|
||||
assert_eq!(settings.environment.provider, EnvironmentProvider::Docker);
|
||||
assert_eq!(
|
||||
settings.environment.image.reference.as_deref(),
|
||||
settings.environment.image.docker.as_deref(),
|
||||
Some("buildpack-deps:noble")
|
||||
);
|
||||
assert_eq!(settings.environment.resources.cpu, Some(2));
|
||||
|
|
@ -89,7 +89,7 @@ preserve = true
|
|||
provider = "daytona"
|
||||
|
||||
[environments.fabro-dev.image]
|
||||
ref = "fabro-v11"
|
||||
dockerfile = "FROM ubuntu:24.04"
|
||||
|
||||
[environments.fabro-dev.resources]
|
||||
cpu = 8
|
||||
|
|
@ -123,7 +123,8 @@ NODE_ENV = "development"
|
|||
|
||||
assert_eq!(environment.id, "fabro-dev");
|
||||
assert_eq!(environment.provider, EnvironmentProvider::Daytona);
|
||||
assert_eq!(environment.image.reference.as_deref(), Some("fabro-v11"));
|
||||
assert_eq!(environment.image.docker.as_deref(), None);
|
||||
assert!(environment.image.dockerfile.is_some());
|
||||
assert_eq!(environment.resources.cpu, Some(8));
|
||||
assert_eq!(
|
||||
environment.resources.memory.map(|size| size.as_bytes()),
|
||||
|
|
@ -390,7 +391,7 @@ provider = "local"
|
|||
|
||||
assert_eq!(settings.environment.id, "host");
|
||||
assert_eq!(settings.environment.provider, EnvironmentProvider::Local);
|
||||
assert!(settings.environment.image.reference.is_none());
|
||||
assert!(settings.environment.image.docker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -465,8 +466,8 @@ mode = "block"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_dockerfile_without_image_ref_errors() {
|
||||
let err = WorkflowSettingsBuilder::from_toml(
|
||||
fn daytona_dockerfile_without_image_ref_resolves() {
|
||||
let settings = WorkflowSettingsBuilder::from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
|
|
@ -480,12 +481,55 @@ provider = "daytona"
|
|||
dockerfile = { path = "Dockerfile" }
|
||||
"#,
|
||||
)
|
||||
.expect_err("daytona dockerfile needs a snapshot name");
|
||||
.expect("daytona dockerfile should not need a user-supplied snapshot name")
|
||||
.run;
|
||||
|
||||
assert_eq!(settings.environment.provider, EnvironmentProvider::Daytona);
|
||||
assert!(settings.environment.image.docker.is_none());
|
||||
assert!(settings.environment.image.dockerfile.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_image_docker_errors() {
|
||||
let err = WorkflowSettingsBuilder::from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.environment]
|
||||
id = "cloud"
|
||||
|
||||
[environments.cloud]
|
||||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
docker = "ubuntu:24.04"
|
||||
"#,
|
||||
)
|
||||
.expect_err("daytona should reject docker image selection");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("image.ref"),
|
||||
"expected daytona dockerfile/image.ref diagnostic, got: {message}"
|
||||
message.contains("image.docker") && message.contains("daytona"),
|
||||
"expected daytona image.docker diagnostic, got: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_ref_is_rejected_as_unknown_field() {
|
||||
let err = WorkflowSettingsBuilder::from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[run.environment.image]
|
||||
ref = "ubuntu:24.04"
|
||||
"#,
|
||||
)
|
||||
.expect_err("image.ref should not be accepted");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("unknown field") && message.contains("ref"),
|
||||
"expected unknown field diagnostic for image.ref, got: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer {
|
|||
.then(|| RunEnvironmentLayer {
|
||||
id: input.environment.map(ToOwned::to_owned),
|
||||
image: input.docker_image.map(|image| EnvironmentImageLayer {
|
||||
reference: Some(image.to_string()),
|
||||
docker: Some(image.to_string()),
|
||||
..EnvironmentImageLayer::default()
|
||||
}),
|
||||
lifecycle: input
|
||||
|
|
@ -1215,7 +1215,6 @@ id = "daytona"
|
|||
provider = "daytona"
|
||||
|
||||
[environments.daytona.image]
|
||||
ref = "fabro-test"
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
"#,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ serde_json.workspace = true
|
|||
strum.workspace = true
|
||||
tracing.workspace = true
|
||||
base64.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
uuid.workspace = true
|
||||
fabro-proc = { path = "../fabro-proc" }
|
||||
fabro-static.workspace = true
|
||||
fabro-util = { path = "../fabro-util" }
|
||||
|
|
@ -66,7 +70,6 @@ rustls = { version = "0.23", default-features = false, features = ["std", "ring"
|
|||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tempfile = "3"
|
||||
uuid.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
httpmock = "0.8"
|
||||
|
|
|
|||
|
|
@ -125,7 +125,6 @@ pub enum DockerfileSource {
|
|||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct DaytonaSnapshotSettings {
|
||||
pub name: String,
|
||||
pub cpu: Option<i32>,
|
||||
pub memory: Option<i32>,
|
||||
pub disk: Option<i32>,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,74 @@ pub use crate::config::{
|
|||
DaytonaSnapshotSettings as DaytonaSnapshotConfig, DaytonaVolumeMount, DockerfileSource,
|
||||
};
|
||||
|
||||
pub mod snapshot_identity {
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{DaytonaSnapshotConfig, DockerfileSource};
|
||||
|
||||
const IDENTITY_VERSION: u8 = 1;
|
||||
const PROVIDER: &str = "daytona";
|
||||
const TENANT: &str = "single-tenant";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
pub fn snapshot_name(api_key: &str, config: &DaytonaSnapshotConfig) -> crate::Result<String> {
|
||||
let manifest = canonical_manifest(config)?;
|
||||
let mut mac = HmacSha256::new_from_slice(api_key.as_bytes())
|
||||
.expect("HMAC-SHA256 accepts keys of any length");
|
||||
mac.update(&manifest);
|
||||
let digest = mac.finalize().into_bytes();
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes.copy_from_slice(&digest[..16]);
|
||||
Ok(format!("fabro-{}", Uuid::new_v8(bytes)))
|
||||
}
|
||||
|
||||
fn canonical_manifest(config: &DaytonaSnapshotConfig) -> crate::Result<Vec<u8>> {
|
||||
let dockerfile = match &config.dockerfile {
|
||||
Some(DockerfileSource::Inline(text)) => text.as_str(),
|
||||
Some(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,
|
||||
};
|
||||
serde_json::to_vec(&manifest).map_err(|err| {
|
||||
crate::Error::context("Failed to serialize Daytona snapshot identity", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DaytonaKeyCheck {
|
||||
pub key_name: String,
|
||||
|
|
@ -128,6 +196,18 @@ async fn build_daytona_client(
|
|||
build_daytona_client_with(api_key, None, None, None).await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Standalone Daytona sandbox construction falls back to the documented process env var."
|
||||
)]
|
||||
fn resolve_daytona_api_key(api_key: Option<String>) -> Option<String> {
|
||||
api_key.filter(|key| !key.is_empty()).or_else(|| {
|
||||
std::env::var(EnvVars::DAYTONA_API_KEY)
|
||||
.ok()
|
||||
.filter(|key| !key.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn build_daytona_client_with(
|
||||
api_key: Option<String>,
|
||||
api_url: Option<String>,
|
||||
|
|
@ -242,8 +322,10 @@ fn command_kind(command: &str) -> &'static str {
|
|||
pub struct DaytonaSandbox {
|
||||
config: DaytonaConfig,
|
||||
client: daytona_sdk::Client,
|
||||
api_key: Option<String>,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
sandbox: OnceCell<daytona_sdk::Sandbox>,
|
||||
snapshot_name: OnceCell<String>,
|
||||
rg_available: OnceCell<bool>,
|
||||
event_callback: Option<SandboxEventCallback>,
|
||||
/// HTTPS origin URL stored after clone so we can refresh push credentials
|
||||
|
|
@ -271,14 +353,17 @@ impl DaytonaSandbox {
|
|||
clone_branch: Option<String>,
|
||||
api_key: Option<String>,
|
||||
) -> crate::Result<Self> {
|
||||
let client = build_daytona_client(api_key)
|
||||
let api_key = resolve_daytona_api_key(api_key);
|
||||
let client = build_daytona_client(api_key.clone())
|
||||
.await
|
||||
.map_err(|e| crate::Error::context("Failed to create Daytona client", e))?;
|
||||
Ok(Self {
|
||||
config,
|
||||
client,
|
||||
api_key,
|
||||
github_app,
|
||||
sandbox: OnceCell::new(),
|
||||
snapshot_name: OnceCell::new(),
|
||||
rg_available: OnceCell::const_new(),
|
||||
event_callback: None,
|
||||
origin_url: OnceCell::new(),
|
||||
|
|
@ -302,7 +387,8 @@ impl DaytonaSandbox {
|
|||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
) -> crate::Result<Self> {
|
||||
let client = build_daytona_client(api_key)
|
||||
let api_key = resolve_daytona_api_key(api_key);
|
||||
let client = build_daytona_client(api_key.clone())
|
||||
.await
|
||||
.map_err(|e| crate::Error::context("Failed to create Daytona client", e))?;
|
||||
let sdk_sandbox = client.get(sandbox_name).await.map_err(|e| {
|
||||
|
|
@ -326,8 +412,10 @@ impl DaytonaSandbox {
|
|||
Ok(Self {
|
||||
config: DaytonaConfig::default(),
|
||||
client,
|
||||
api_key,
|
||||
github_app: None,
|
||||
sandbox: sandbox_cell,
|
||||
snapshot_name: OnceCell::new(),
|
||||
rg_available: OnceCell::const_new(),
|
||||
event_callback: None,
|
||||
origin_url,
|
||||
|
|
@ -477,8 +565,12 @@ impl DaytonaSandbox {
|
|||
/// 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.
|
||||
async fn ensure_snapshot(&self, snap_cfg: &DaytonaSnapshotConfig) -> crate::Result<()> {
|
||||
match self.client.snapshot.get(&snap_cfg.name).await {
|
||||
async fn ensure_snapshot(
|
||||
&self,
|
||||
name: &str,
|
||||
snap_cfg: &DaytonaSnapshotConfig,
|
||||
) -> crate::Result<()> {
|
||||
match self.client.snapshot.get(name).await {
|
||||
Ok(dto) => {
|
||||
use daytona_api_client::models::SnapshotState;
|
||||
match dto.state {
|
||||
|
|
@ -486,7 +578,7 @@ impl DaytonaSandbox {
|
|||
SnapshotState::Error | SnapshotState::BuildFailed => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Snapshot '{}' is in state '{}': {}",
|
||||
snap_cfg.name,
|
||||
name,
|
||||
dto.state,
|
||||
dto.error_reason.unwrap_or_default()
|
||||
)));
|
||||
|
|
@ -494,7 +586,7 @@ impl DaytonaSandbox {
|
|||
_ => {
|
||||
// Building/Pending/Pulling — fall through to poll
|
||||
self.emit(SandboxEvent::SnapshotCreating {
|
||||
name: snap_cfg.name.clone(),
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -504,24 +596,22 @@ impl DaytonaSandbox {
|
|||
Some(DockerfileSource::Inline(s)) => s.as_str(),
|
||||
Some(DockerfileSource::Path { .. }) => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Snapshot '{}': dockerfile path should have been resolved to inline content before sandbox creation",
|
||||
snap_cfg.name
|
||||
"Snapshot '{name}': dockerfile path should have been resolved to inline content before sandbox creation"
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Snapshot '{}' does not exist and no dockerfile provided to create it",
|
||||
snap_cfg.name
|
||||
"Snapshot '{name}' does not exist and no dockerfile provided to create it"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
self.emit(SandboxEvent::SnapshotCreating {
|
||||
name: snap_cfg.name.clone(),
|
||||
name: name.to_string(),
|
||||
});
|
||||
|
||||
let params = daytona_sdk::CreateSnapshotParams {
|
||||
name: snap_cfg.name.clone(),
|
||||
name: name.to_string(),
|
||||
image: daytona_sdk::ImageSource::Custom(
|
||||
daytona_sdk::DockerImage::from_dockerfile(dockerfile),
|
||||
),
|
||||
|
|
@ -534,22 +624,19 @@ impl DaytonaSandbox {
|
|||
entrypoint: None,
|
||||
};
|
||||
self.client.snapshot.create(¶ms).await.map_err(|e| {
|
||||
crate::Error::context(
|
||||
format!("Failed to create snapshot '{}'", snap_cfg.name),
|
||||
e,
|
||||
)
|
||||
crate::Error::context(format!("Failed to create snapshot '{name}'"), e)
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(crate::Error::context(
|
||||
format!("Failed to get snapshot '{}'", snap_cfg.name),
|
||||
format!("Failed to get snapshot '{name}'"),
|
||||
e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Poll until Active (or terminal failure).
|
||||
self.poll_snapshot_active(&snap_cfg.name).await
|
||||
self.poll_snapshot_active(name).await
|
||||
}
|
||||
|
||||
/// Poll a snapshot until it reaches `Active` state, with exponential
|
||||
|
|
@ -689,11 +776,29 @@ impl Sandbox for DaytonaSandbox {
|
|||
});
|
||||
let init_start = Instant::now();
|
||||
|
||||
let params = if let Some(ref snap_cfg) = self.config.snapshot {
|
||||
let params = if let Some(snap_cfg) = self
|
||||
.config
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.filter(|snapshot| snapshot.dockerfile.is_some())
|
||||
{
|
||||
let api_key = self.api_key.as_deref().ok_or_else(|| {
|
||||
self.fail_init(
|
||||
init_start,
|
||||
crate::Error::message(format!(
|
||||
"{} is required to compute Daytona snapshot identity",
|
||||
EnvVars::DAYTONA_API_KEY
|
||||
)),
|
||||
)
|
||||
})?;
|
||||
let snapshot_name = match snapshot_identity::snapshot_name(api_key, snap_cfg) {
|
||||
Ok(name) => name,
|
||||
Err(err) => return Err(self.fail_init(init_start, err)),
|
||||
};
|
||||
let snap_start = Instant::now();
|
||||
if let Err(e) = self.ensure_snapshot(snap_cfg).await {
|
||||
if let Err(e) = self.ensure_snapshot(&snapshot_name, snap_cfg).await {
|
||||
self.emit(SandboxEvent::SnapshotFailed {
|
||||
name: snap_cfg.name.clone(),
|
||||
name: snapshot_name.clone(),
|
||||
error: e.to_string(),
|
||||
causes: e.causes(),
|
||||
});
|
||||
|
|
@ -701,15 +806,17 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
let snap_duration = u64::try_from(snap_start.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
self.emit(SandboxEvent::SnapshotReady {
|
||||
name: snap_cfg.name.clone(),
|
||||
name: snapshot_name.clone(),
|
||||
duration_ms: snap_duration,
|
||||
});
|
||||
let _ = self.snapshot_name.set(snapshot_name.clone());
|
||||
|
||||
daytona_sdk::CreateParams::Snapshot(daytona_sdk::SnapshotParams {
|
||||
base: self.base_params(),
|
||||
snapshot: snap_cfg.name.clone(),
|
||||
snapshot: snapshot_name,
|
||||
})
|
||||
} else {
|
||||
let _ = self.snapshot_name.set(DEFAULT_SNAPSHOT.to_string());
|
||||
daytona_sdk::CreateParams::Snapshot(daytona_sdk::SnapshotParams {
|
||||
base: self.base_params(),
|
||||
snapshot: DEFAULT_SNAPSHOT.to_string(),
|
||||
|
|
@ -1144,6 +1251,10 @@ impl Sandbox for DaytonaSandbox {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn snapshot_info(&self) -> Option<String> {
|
||||
self.snapshot_name.get().cloned()
|
||||
}
|
||||
|
||||
async fn setup_git(
|
||||
&self,
|
||||
intent: &crate::GitSetupIntent,
|
||||
|
|
@ -2200,6 +2311,57 @@ mod tests {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn mock_daytona_sandbox(
|
||||
server: &MockServer,
|
||||
api_key: &str,
|
||||
config: DaytonaConfig,
|
||||
) -> DaytonaSandbox {
|
||||
let client = build_daytona_client_with(
|
||||
Some(api_key.to_string()),
|
||||
Some(server.base_url()),
|
||||
None,
|
||||
Some(fabro_test::test_http_client()),
|
||||
)
|
||||
.await
|
||||
.expect("mock Daytona client should build");
|
||||
|
||||
DaytonaSandbox {
|
||||
config,
|
||||
client,
|
||||
api_key: Some(api_key.to_string()),
|
||||
github_app: None,
|
||||
sandbox: OnceCell::new(),
|
||||
snapshot_name: OnceCell::new(),
|
||||
rg_available: OnceCell::const_new(),
|
||||
event_callback: None,
|
||||
origin_url: OnceCell::new(),
|
||||
repo_cloned: OnceCell::new(),
|
||||
working_directory: OnceCell::new(),
|
||||
run_id: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_body(name: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": name,
|
||||
"name": name,
|
||||
"state": "active",
|
||||
"general": false,
|
||||
"cpu": 2.0,
|
||||
"gpu": 0.0,
|
||||
"mem": 4.0,
|
||||
"disk": 20.0,
|
||||
"size": null,
|
||||
"entrypoint": null,
|
||||
"errorReason": null,
|
||||
"lastUsedAt": null,
|
||||
"createdAt": "2026-05-01T00:00:00Z",
|
||||
"updatedAt": "2026-05-01T00:00:00Z"
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_config_defaults() {
|
||||
let config = DaytonaConfig::default();
|
||||
|
|
@ -2247,6 +2409,123 @@ subpath = "agents"
|
|||
assert_eq!(volumes[0].subpath.as_deref(), Some("agents"));
|
||||
}
|
||||
|
||||
#[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(
|
||||
"FROM ubuntu:24.04\nRUN apt-get update".to_string(),
|
||||
)),
|
||||
};
|
||||
|
||||
let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap();
|
||||
let second = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap();
|
||||
let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &config).unwrap();
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_ne!(first, rotated_key);
|
||||
let uuid = first
|
||||
.strip_prefix("fabro-")
|
||||
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
|
||||
.expect("snapshot name should be fabro-<uuid>");
|
||||
assert_eq!(uuid.get_version_num(), 8);
|
||||
assert_eq!(uuid.get_variant(), uuid::Variant::RFC4122);
|
||||
}
|
||||
|
||||
#[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())),
|
||||
};
|
||||
let base_name = snapshot_identity::snapshot_name("dtn_secret", &base).unwrap();
|
||||
|
||||
let cases = [
|
||||
DaytonaSnapshotConfig {
|
||||
dockerfile: Some(DockerfileSource::Inline(
|
||||
"FROM ubuntu:24.04\n# roll cache".to_string(),
|
||||
)),
|
||||
..base.clone()
|
||||
},
|
||||
DaytonaSnapshotConfig {
|
||||
cpu: Some(4),
|
||||
..base.clone()
|
||||
},
|
||||
DaytonaSnapshotConfig {
|
||||
memory: Some(8),
|
||||
..base.clone()
|
||||
},
|
||||
DaytonaSnapshotConfig {
|
||||
disk: Some(20),
|
||||
..base.clone()
|
||||
},
|
||||
];
|
||||
|
||||
for changed in cases {
|
||||
let changed_name = snapshot_identity::snapshot_name("dtn_secret", &changed).unwrap();
|
||||
assert_ne!(base_name, changed_name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_snapshot_identity_excludes_raw_dockerfile_and_key_material() {
|
||||
let config = DaytonaSnapshotConfig {
|
||||
cpu: None,
|
||||
memory: None,
|
||||
disk: None,
|
||||
dockerfile: Some(DockerfileSource::Inline(
|
||||
"FROM private.example.com/secret-image\nRUN echo raw-secret".to_string(),
|
||||
)),
|
||||
};
|
||||
|
||||
let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &config).unwrap();
|
||||
|
||||
assert!(name.starts_with("fabro-"));
|
||||
assert!(!name.contains("private.example.com"));
|
||||
assert!(!name.contains("raw-secret"));
|
||||
assert!(!name.contains("dtn_super_secret_key"));
|
||||
}
|
||||
|
||||
#[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())),
|
||||
};
|
||||
let computed_name = snapshot_identity::snapshot_name(api_key, &snapshot).unwrap();
|
||||
let server = MockServer::start_async().await;
|
||||
let path = format!("/snapshots/{computed_name}");
|
||||
let get_snapshot = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET)
|
||||
.path(path.as_str())
|
||||
.header("authorization", "Bearer dtn_secret");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(snapshot_body(&computed_name));
|
||||
})
|
||||
.await;
|
||||
let config = DaytonaConfig {
|
||||
snapshot: Some(snapshot.clone()),
|
||||
..DaytonaConfig::default()
|
||||
};
|
||||
let sandbox = mock_daytona_sandbox(&server, api_key, config).await;
|
||||
|
||||
sandbox
|
||||
.ensure_snapshot(&computed_name, &snapshot)
|
||||
.await
|
||||
.expect("existing computed snapshot should be accepted");
|
||||
|
||||
get_snapshot.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_params_create_run_owned_non_ephemeral_sandbox() {
|
||||
let sandbox = DaytonaSandbox::new(
|
||||
|
|
|
|||
|
|
@ -40,10 +40,9 @@ pub fn daytona_config_from_environment(
|
|||
.collect(),
|
||||
snapshot: settings
|
||||
.image
|
||||
.reference
|
||||
.dockerfile
|
||||
.as_ref()
|
||||
.map(|name| DaytonaSnapshotSettings {
|
||||
name: name.clone(),
|
||||
.map(|dockerfile| DaytonaSnapshotSettings {
|
||||
cpu: settings.resources.cpu,
|
||||
memory: settings
|
||||
.resources
|
||||
|
|
@ -53,18 +52,14 @@ pub fn daytona_config_from_environment(
|
|||
.resources
|
||||
.disk
|
||||
.map(|size| size_to_gb_i32(size.as_bytes())),
|
||||
dockerfile: settings
|
||||
.image
|
||||
.dockerfile
|
||||
.as_ref()
|
||||
.map(|dockerfile| match dockerfile {
|
||||
ResolvedDockerfileSource::Inline(text) => {
|
||||
SandboxDockerfileSource::Inline(text.clone())
|
||||
}
|
||||
ResolvedDockerfileSource::Path { path } => {
|
||||
SandboxDockerfileSource::Path { path: path.clone() }
|
||||
}
|
||||
}),
|
||||
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 {
|
||||
EnvironmentNetworkMode::Block => DaytonaNetwork::Block,
|
||||
|
|
@ -94,7 +89,7 @@ pub fn docker_config_from_environment(
|
|||
DockerSandboxOptions {
|
||||
image: settings
|
||||
.image
|
||||
.reference
|
||||
.docker
|
||||
.clone()
|
||||
.unwrap_or(default_options.image),
|
||||
network_mode: match settings.network.mode {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,10 @@ macro_rules! delegate_sandbox {
|
|||
self.$field.sandbox_info()
|
||||
}
|
||||
|
||||
fn snapshot_info(&self) -> Option<String> {
|
||||
self.$field.snapshot_info()
|
||||
}
|
||||
|
||||
async fn refresh_push_credentials(&self) -> $crate::Result<()> {
|
||||
self.$field.refresh_push_credentials().await
|
||||
}
|
||||
|
|
@ -948,6 +952,12 @@ pub trait Sandbox: Send + Sync {
|
|||
String::new()
|
||||
}
|
||||
|
||||
/// Return the provider snapshot used by an initialized sandbox, when the
|
||||
/// provider has a snapshot concept.
|
||||
fn snapshot_info(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Refresh git push credentials (e.g. rotate an expiring GitHub App token).
|
||||
/// Default is a no-op; Daytona overrides to update the remote URL with a
|
||||
/// fresh token.
|
||||
|
|
|
|||
|
|
@ -136,10 +136,7 @@ impl SandboxSpec {
|
|||
RunSandbox {
|
||||
provider: self.provider(),
|
||||
image: None,
|
||||
snapshot: config
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.name.clone()),
|
||||
snapshot: sandbox.snapshot_info(),
|
||||
runtime: Some(RunSandboxRuntime {
|
||||
id,
|
||||
working_directory: working_directory.clone(),
|
||||
|
|
|
|||
|
|
@ -1757,7 +1757,7 @@ mod runs {
|
|||
let environment = EnvironmentSettings {
|
||||
provider: EnvironmentProvider::Daytona,
|
||||
image: EnvironmentImageSettings {
|
||||
reference: Some("api-server-dev".into()),
|
||||
docker: Some("api-server-dev".into()),
|
||||
dockerfile: None,
|
||||
},
|
||||
resources: EnvironmentResourcesSettings {
|
||||
|
|
|
|||
|
|
@ -1546,7 +1546,6 @@ id = "cloud"
|
|||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
ref = "fabro-test"
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
"#
|
||||
.to_string(),
|
||||
|
|
@ -1606,7 +1605,6 @@ id = "cloud"
|
|||
provider = "daytona"
|
||||
|
||||
[environments.cloud.image]
|
||||
ref = "fabro-test"
|
||||
dockerfile = { path = "Dockerfile" }
|
||||
"#
|
||||
.to_string(),
|
||||
|
|
|
|||
|
|
@ -11624,6 +11624,8 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
|
|||
provider: SandboxProviderKind::Local,
|
||||
id: "sandbox-preserve-1".to_string(),
|
||||
working_directory: "/tmp/fabro-preserved-sandbox".to_string(),
|
||||
image: None,
|
||||
snapshot: None,
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
|
|
@ -12375,6 +12377,8 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
|
|||
provider: SandboxProviderKind::Docker,
|
||||
id: "missing-sandbox".to_string(),
|
||||
working_directory: "/tmp/fabro-missing-sandbox".to_string(),
|
||||
image: None,
|
||||
snapshot: None,
|
||||
repo_cloned: Some(false),
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
|
|
@ -14269,6 +14273,8 @@ async fn list_runs_includes_live_metadata_from_run_state() {
|
|||
provider: SandboxProviderKind::Local,
|
||||
id: "sb-test".to_string(),
|
||||
working_directory: "/sandbox/workdir".to_string(),
|
||||
image: None,
|
||||
snapshot: None,
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
|
|
@ -14348,6 +14354,8 @@ async fn list_runs_page_limit_preserves_metadata_for_paged_items() {
|
|||
provider: SandboxProviderKind::Local,
|
||||
id: sandbox_id.to_string(),
|
||||
working_directory: "/sandbox/workdir".to_string(),
|
||||
image: None,
|
||||
snapshot: None,
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
|
|
|
|||
|
|
@ -267,6 +267,8 @@ impl RunProjectionReducer for RunProjection {
|
|||
runtime: None,
|
||||
});
|
||||
sandbox.provider = props.provider;
|
||||
sandbox.image.clone_from(&props.image);
|
||||
sandbox.snapshot.clone_from(&props.snapshot);
|
||||
sandbox.runtime = Some(RunSandboxRuntime {
|
||||
id: props.id.clone(),
|
||||
working_directory: props.working_directory.clone(),
|
||||
|
|
@ -800,12 +802,10 @@ fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox {
|
|||
RunSandbox {
|
||||
provider,
|
||||
image: (settings.provider == EnvironmentProvider::Docker)
|
||||
.then(|| settings.image.reference.clone())
|
||||
.then(|| settings.image.docker.clone())
|
||||
.flatten()
|
||||
.filter(|image| !image.is_empty()),
|
||||
snapshot: (settings.provider == EnvironmentProvider::Daytona)
|
||||
.then(|| settings.image.reference.clone())
|
||||
.flatten(),
|
||||
snapshot: None,
|
||||
runtime: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -1246,6 +1246,7 @@ mod tests {
|
|||
RunCompletedProps, RunControlEffectProps, StageCompletedProps, StageFailedProps,
|
||||
StagePromptProps, StageRetryingProps, StageStartedProps,
|
||||
};
|
||||
use fabro_types::settings::run::{DockerfileSource, EnvironmentProvider};
|
||||
use fabro_types::{
|
||||
AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint,
|
||||
CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail,
|
||||
|
|
@ -1371,6 +1372,50 @@ mod tests {
|
|||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_sandbox_uses_docker_image_and_hides_daytona_snapshot_until_init() {
|
||||
let mut docker = WorkflowSettings::default().run.environment;
|
||||
docker.provider = EnvironmentProvider::Docker;
|
||||
docker.image.docker = Some("ubuntu:24.04".to_string());
|
||||
|
||||
let planned_docker = super::planned_sandbox(&docker);
|
||||
assert_eq!(planned_docker.image.as_deref(), Some("ubuntu:24.04"));
|
||||
assert_eq!(planned_docker.snapshot, None);
|
||||
|
||||
let mut daytona = WorkflowSettings::default().run.environment;
|
||||
daytona.provider = EnvironmentProvider::Daytona;
|
||||
daytona.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string()));
|
||||
|
||||
let planned_daytona = super::planned_sandbox(&daytona);
|
||||
assert_eq!(planned_daytona.image, None);
|
||||
assert_eq!(planned_daytona.snapshot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_initialized_updates_image_and_snapshot_projection_fields() {
|
||||
let mut state = initialized_projection();
|
||||
state
|
||||
.apply_event(&test_raw_event(
|
||||
1,
|
||||
"sandbox.initialized",
|
||||
&json!({
|
||||
"provider": "daytona",
|
||||
"id": "fabro-run-sandbox",
|
||||
"working_directory": "/home/daytona/workspace",
|
||||
"snapshot": "fabro-11111111-2222-8333-8444-555555555555"
|
||||
}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let sandbox = state.sandbox.expect("sandbox should be projected");
|
||||
assert_eq!(sandbox.image, None);
|
||||
assert_eq!(
|
||||
sandbox.snapshot.as_deref(),
|
||||
Some("fabro-11111111-2222-8333-8444-555555555555")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_run_created_projects_retried_from_none() {
|
||||
let event = test_raw_event(
|
||||
|
|
|
|||
|
|
@ -332,6 +332,10 @@ pub struct SandboxInitializedProps {
|
|||
pub provider: SandboxProviderKind,
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub snapshot: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repo_cloned: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clone_origin_url: Option<String>,
|
||||
|
|
|
|||
|
|
@ -755,8 +755,7 @@ pub enum EnvironmentNetworkMode {
|
|||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EnvironmentImageSettings {
|
||||
#[serde(rename = "ref")]
|
||||
pub reference: Option<String>,
|
||||
pub docker: Option<String>,
|
||||
pub dockerfile: Option<DockerfileSource>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,23 +4,14 @@ use fabro_agent::sandbox::Sandbox;
|
|||
use fabro_devcontainer::DevcontainerSpec;
|
||||
use fabro_sandbox::daytona::{DaytonaSnapshotConfig, DockerfileSource};
|
||||
use futures::future::try_join_all;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::event::{Emitter, Event};
|
||||
|
||||
/// Compute a deterministic snapshot name from Dockerfile content.
|
||||
pub fn snapshot_name_for_dockerfile(dockerfile: &str) -> String {
|
||||
let hash = Sha256::digest(dockerfile.as_bytes());
|
||||
let hex = hex::encode(hash);
|
||||
format!("devcontainer-{}", &hex[..12])
|
||||
}
|
||||
|
||||
/// Map a `DevcontainerSpec` to a `DaytonaSnapshotConfig`.
|
||||
pub fn devcontainer_to_snapshot_config(dc: &DevcontainerSpec) -> DaytonaSnapshotConfig {
|
||||
DaytonaSnapshotConfig {
|
||||
name: snapshot_name_for_dockerfile(&dc.dockerfile),
|
||||
dockerfile: Some(DockerfileSource::Inline(dc.dockerfile.clone())),
|
||||
cpu: None,
|
||||
memory: None,
|
||||
|
|
@ -379,29 +370,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_name_is_deterministic() {
|
||||
let dockerfile = "FROM ubuntu:22.04\nRUN apt-get update";
|
||||
let name1 = snapshot_name_for_dockerfile(dockerfile);
|
||||
let name2 = snapshot_name_for_dockerfile(dockerfile);
|
||||
assert_eq!(name1, name2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_name_differs_for_different_dockerfiles() {
|
||||
let name1 = snapshot_name_for_dockerfile("FROM ubuntu:22.04");
|
||||
let name2 = snapshot_name_for_dockerfile("FROM rust:1.85");
|
||||
assert_ne!(name1, name2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_name_has_prefix() {
|
||||
let name = snapshot_name_for_dockerfile("FROM ubuntu:22.04");
|
||||
assert!(name.starts_with("devcontainer-"), "name: {name}");
|
||||
// prefix + 12 hex chars
|
||||
assert_eq!(name.len(), "devcontainer-".len() + 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_dockerfile_to_inline() {
|
||||
let dc = test_devcontainer_config("FROM rust:1.85\nRUN cargo install sccache");
|
||||
|
|
@ -413,11 +381,12 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_name_from_dockerfile_hash() {
|
||||
fn devcontainer_snapshot_uses_runtime_daytona_identity_path() {
|
||||
let dc = test_devcontainer_config("FROM ubuntu:22.04");
|
||||
let snapshot = devcontainer_to_snapshot_config(&dc);
|
||||
let expected = snapshot_name_for_dockerfile(&dc.dockerfile);
|
||||
assert_eq!(snapshot.name, expected);
|
||||
assert_eq!(snapshot.cpu, None);
|
||||
assert_eq!(snapshot.memory, None);
|
||||
assert_eq!(snapshot.disk, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -1015,6 +1015,8 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
working_directory,
|
||||
provider,
|
||||
id,
|
||||
image,
|
||||
snapshot,
|
||||
repo_cloned,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
|
|
@ -1026,6 +1028,8 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
working_directory: working_directory.clone(),
|
||||
provider: *provider,
|
||||
id: id.clone(),
|
||||
image: image.clone(),
|
||||
snapshot: snapshot.clone(),
|
||||
repo_cloned: *repo_cloned,
|
||||
clone_origin_url: clone_origin_url.clone(),
|
||||
clone_branch: clone_branch.clone(),
|
||||
|
|
|
|||
|
|
@ -509,6 +509,10 @@ pub enum Event {
|
|||
provider: SandboxProviderKind,
|
||||
id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
image: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
snapshot: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
repo_cloned: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
clone_origin_url: Option<String>,
|
||||
|
|
|
|||
|
|
@ -304,6 +304,8 @@ mod tests {
|
|||
provider: fabro_types::SandboxProviderKind::Local,
|
||||
id: "sandbox-source".to_string(),
|
||||
working_directory: "/tmp/source".to_string(),
|
||||
image: None,
|
||||
snapshot: None,
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
|
|
|
|||
|
|
@ -1302,7 +1302,7 @@ reasoning = false
|
|||
let settings = settings_from_run_layer(RunLayer {
|
||||
environment: Some(RunEnvironmentLayer {
|
||||
image: Some(EnvironmentImageLayer {
|
||||
reference: Some("ubuntu:24.04".to_string()),
|
||||
docker: Some("ubuntu:24.04".to_string()),
|
||||
..EnvironmentImageLayer::default()
|
||||
}),
|
||||
resources: Some(EnvironmentResourcesLayer {
|
||||
|
|
|
|||
|
|
@ -519,6 +519,8 @@ pub async fn initialize(
|
|||
working_directory: runtime.working_directory.clone(),
|
||||
provider: run_sandbox.provider,
|
||||
id: runtime.id.clone(),
|
||||
image: run_sandbox.image.clone(),
|
||||
snapshot: run_sandbox.snapshot.clone(),
|
||||
repo_cloned: runtime.repo_cloned,
|
||||
clone_origin_url: runtime.clone_origin_url.clone(),
|
||||
clone_branch: runtime.clone_branch.clone(),
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ use std::sync::Arc;
|
|||
|
||||
use fabro_agent::Sandbox;
|
||||
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
||||
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig};
|
||||
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::{ArtifactKey, ArtifactStore, Database};
|
||||
use fabro_types::{RunId, StageId, WorkflowSettings};
|
||||
|
|
@ -368,7 +368,6 @@ async fn daytona_snapshot_sandbox() {
|
|||
let config = DaytonaConfig {
|
||||
auto_stop_interval: Some(60),
|
||||
snapshot: Some(DaytonaSnapshotConfig {
|
||||
name: "fabro-test-snapshot".to_string(),
|
||||
cpu: Some(2),
|
||||
memory: Some(4),
|
||||
disk: Some(10),
|
||||
|
|
@ -1804,13 +1803,7 @@ async fn daytona_cp_upload_download_round_trip() {
|
|||
async fn daytona_computer_use_browser_screenshot() {
|
||||
use base64::Engine;
|
||||
let config = DaytonaConfig {
|
||||
snapshot: Some(DaytonaSnapshotConfig {
|
||||
name: "daytona-medium".into(),
|
||||
cpu: None,
|
||||
memory: None,
|
||||
disk: None,
|
||||
dockerfile: None,
|
||||
}),
|
||||
snapshot: None,
|
||||
skip_clone: true,
|
||||
..DaytonaConfig::default()
|
||||
};
|
||||
|
|
@ -1958,13 +1951,7 @@ async fn daytona_playwright_mcp_sandbox_transport() {
|
|||
|
||||
// Create sandbox from daytona-medium (has Node.js + Chromium)
|
||||
let config = DaytonaConfig {
|
||||
snapshot: Some(DaytonaSnapshotConfig {
|
||||
name: "daytona-medium".into(),
|
||||
cpu: None,
|
||||
memory: None,
|
||||
disk: None,
|
||||
dockerfile: None,
|
||||
}),
|
||||
snapshot: None,
|
||||
skip_clone: true,
|
||||
..DaytonaConfig::default()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { DaytonaNetworkLayer } from './daytona-network-layer';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { DaytonaSnapshotSettings } from './daytona-snapshot-settings';
|
||||
|
||||
export interface DaytonaSettings {
|
||||
'auto_stop_interval': number | null;
|
||||
'labels': { [key: string]: string; };
|
||||
'snapshot': DaytonaSnapshotSettings | null;
|
||||
'network': DaytonaNetworkLayer | null;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { DockerfileSource } from './dockerfile-source';
|
||||
|
||||
export interface DaytonaSnapshotSettings {
|
||||
'name': string;
|
||||
'cpu': number | null;
|
||||
'memory_gb': number | null;
|
||||
'disk_gb': number | null;
|
||||
'dockerfile': DockerfileSource | null;
|
||||
}
|
||||
|
|
@ -18,6 +18,6 @@
|
|||
import type { DockerfileSource } from './dockerfile-source';
|
||||
|
||||
export interface EnvironmentImageSettings {
|
||||
'ref': string | null;
|
||||
'docker': string | null;
|
||||
'dockerfile': DockerfileSource | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { DaytonaSettings } from './daytona-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { DockerSettings } from './docker-settings';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SandboxProvider } from './sandbox-provider';
|
||||
|
||||
export interface RunSandboxSettings {
|
||||
'provider': SandboxProvider;
|
||||
'preserve': boolean;
|
||||
'stop_on_terminal': boolean;
|
||||
'devcontainer': boolean;
|
||||
'env': { [key: string]: string; };
|
||||
'docker': DockerSettings | null;
|
||||
'daytona': DaytonaSettings | null;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue