mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-05 02:41:45 +00:00
## Summary
Replaces the `[run.sandbox]` configuration surface with a named,
provider-explicit environment catalog. Runs now select an environment by
slug (`[run.environment] id = "..."`) rather than configuring a sandbox
inline. Fabro resolves the catalog through normal settings precedence,
applies sparse run-level overrides, and creates a concrete sandbox from
the resolved environment.
This is a clean break — no `[run.sandbox]` compatibility layer.
### Plan Summary
- **New config shape:** Top-level `[environments.<slug>]` catalog valid
in `settings.toml`, `.fabro/project.toml`, and `workflow.toml`. Runs
reference a slug via `[run.environment] id = "..."` with optional sparse
overrides under `[run.environment.*]`.
- **Unified environment fields:** `provider`, `image` (ref +
dockerfile), `resources` (cpu/memory/disk), `network` (mode + allow
CIDRs), `lifecycle` (preserve/stop_on_terminal/auto_stop), `labels`,
`volumes`, `env` — replacing the previous split between `[run.sandbox]`,
`[run.sandbox.docker]`, `[run.sandbox.daytona]`, and
`[run.sandbox.daytona.snapshot]`.
- **OpenAPI schema update:** `RunSandboxSettings`, `DockerSettings`,
`DaytonaSettings`, and `DaytonaNetworkLayer` replaced with
`RunEnvironmentSettings`, `EnvironmentSettings`, `EnvironmentProvider`,
`EnvironmentImageSettings`, `EnvironmentResourcesSettings`,
`EnvironmentNetworkSettings`, `EnvironmentLifecycleSettings`, and
`EnvironmentVolumeSettings`.
- **CLI flag rename:** `--sandbox <provider>` → `--environment <slug>`
on `run`, `create`, `preflight`, and `server start/restart`.
- **Provider capability model:** Hard errors for security properties a
provider cannot enforce (local with blocked/CIDR networking; docker with
CIDR allow-lists). Warnings for unsupported resource limits, volumes,
labels, auto-stop, and Docker Dockerfiles.
- **Docs and internal code updated** throughout: `.fabro/project.toml`,
workflow configs, all public docs, CLI args, manifest builders, and the
runner's GitHub credentials check.
### Provider mapping
| Environment field | Local | Docker | Daytona |
|---|---|---|---|
| `image.ref` | Ignored | Docker image | Snapshot name |
| `image.dockerfile` | Ignored | Warning; ignored | Snapshot Dockerfile
(requires `image.ref`) |
| `resources.cpu/memory/disk` | Warning; ignored | cpu_quota / memory
limit / warning | Snapshot sizing |
| `network.mode = block` | **Error** | `network_mode = none` | Daytona
block |
| `network.mode = cidr_allow_list` | **Error** | **Error** | Daytona
CIDR allow-list |
| `labels` | Warning; ignored | Warning; ignored | Daytona labels |
| `volumes` | Warning; ignored | Warning; ignored | Daytona volume
mounts |
| `lifecycle.auto_stop` | Warning; ignored | Warning; ignored | Daytona
auto-stop interval |
| `env` | Process env overlay | Container env | Sandbox env |
### Fabro Details
<details>
<summary>Ran 11 stages in 217m 39s for $129.86</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 4m 7s | – | 0 |
| preflight_lint | 4m 9s | – | 0 |
| fix_lints | 3m 46s | $1.06 | 0 |
| implement | 76m 6s | $57.39 | 0 |
| simplify_opus | 71m 50s | $38.17 | 0 |
| simplify_gpt | 8m 27s | $2.24 | 0 |
| verify | 6m 10s | – | 0 |
| fixup | 42m 1s | $31.00 | 0 |
| fmt | 3s | – | 0 |
| **Total** | **217m 39s** | **$129.86** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Bryan Helmkamp <bhelmkamp@users.noreply.github.com>
358 lines
11 KiB
Rust
358 lines
11 KiB
Rust
#![expect(
|
||
clippy::disallowed_methods,
|
||
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
|
||
)]
|
||
|
||
use std::fs;
|
||
use std::time::Duration;
|
||
|
||
use fabro_client::ServerTarget;
|
||
use fabro_test::{fabro_snapshot, test_context};
|
||
use insta::assert_snapshot;
|
||
|
||
use super::support::{
|
||
local_dev_token, server_target, setup_completed_dry_run, setup_seeded_completed_dry_run,
|
||
setup_seeded_created_dry_run,
|
||
};
|
||
use crate::support::{LightweightCli, seed_dev_token_auth, unique_run_id};
|
||
|
||
#[test]
|
||
fn help() {
|
||
let context = test_context!();
|
||
let mut cmd = context.command();
|
||
cmd.args(["dump", "--help"]);
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: true
|
||
exit_code: 0
|
||
----- stdout -----
|
||
Export a run's durable state to a directory
|
||
|
||
Usage: fabro dump [OPTIONS] --output <OUTPUT> <RUN>
|
||
|
||
Arguments:
|
||
<RUN> Run ID prefix or workflow name
|
||
|
||
Options:
|
||
--json Output as JSON [env: FABRO_JSON=]
|
||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||
-o, --output <OUTPUT> Output directory (must not exist or be empty)
|
||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
||
-h, --help Print help
|
||
----- stderr -----
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn dump_accepts_server_target_from_separate_home() {
|
||
let context = test_context!();
|
||
let run = setup_seeded_completed_dry_run(&context);
|
||
let cli = LightweightCli::new();
|
||
let output_dir = context.temp_dir.join("remote-export");
|
||
let server = server_target(&context.storage_dir);
|
||
if let Some(dev_token) = local_dev_token(&context.storage_dir) {
|
||
let target = server
|
||
.parse::<ServerTarget>()
|
||
.expect("server target should parse");
|
||
seed_dev_token_auth(cli.home(), &target, &dev_token);
|
||
}
|
||
|
||
let mut cmd = cli.command();
|
||
cmd.args([
|
||
"dump",
|
||
"--server",
|
||
&server,
|
||
"--output",
|
||
output_dir.to_str().unwrap(),
|
||
&run.run_id,
|
||
]);
|
||
|
||
let output = cmd.output().expect("dump should execute");
|
||
assert!(
|
||
output.status.success(),
|
||
"dump via remote server target failed\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
assert!(output_dir.join("run.json").is_file());
|
||
}
|
||
|
||
#[test]
|
||
fn dump_exports_large_command_output_backed_by_blob_refs() {
|
||
let context = test_context!();
|
||
let workflow = context.temp_dir.join("large-output.fabro");
|
||
fs::write(
|
||
&workflow,
|
||
r#"digraph LargeOutput {
|
||
graph [goal="Generate oversized command output"]
|
||
rankdir=LR
|
||
|
||
start [shape=Mdiamond, label="Start"]
|
||
exit [shape=Msquare, label="Exit"]
|
||
big [shape=parallelogram, label="Big", script="printf '%*s' 120000 '' | tr ' ' x"]
|
||
|
||
start -> big -> exit
|
||
}
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let run_id = unique_run_id();
|
||
let mut run_cmd = context.run_cmd();
|
||
run_cmd.current_dir(&context.temp_dir);
|
||
run_cmd.timeout(Duration::from_secs(30));
|
||
run_cmd.args(["--run-id", run_id.as_str(), "--environment", "local"]);
|
||
run_cmd.arg(&workflow);
|
||
let run_output = run_cmd.output().expect("command should execute");
|
||
assert!(
|
||
run_output.status.success(),
|
||
"workflow run failed\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&run_output.stdout),
|
||
String::from_utf8_lossy(&run_output.stderr)
|
||
);
|
||
|
||
let mut inspect_cmd = context.command();
|
||
inspect_cmd.args(["inspect", "--json", &run_id]);
|
||
let inspect_output = inspect_cmd.output().expect("inspect should execute");
|
||
assert!(
|
||
inspect_output.status.success(),
|
||
"inspect failed\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&inspect_output.stdout),
|
||
String::from_utf8_lossy(&inspect_output.stderr)
|
||
);
|
||
let inspect_json = String::from_utf8(inspect_output.stdout).unwrap();
|
||
assert!(
|
||
inspect_json.contains("blob://sha256/"),
|
||
"inspect output should contain blob refs to exercise hydration\n{inspect_json}"
|
||
);
|
||
|
||
let output_dir = context.temp_dir.join("export");
|
||
let mut dump_cmd = context.command();
|
||
dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]);
|
||
let dump_output = dump_cmd.output().expect("dump should execute");
|
||
assert!(
|
||
dump_output.status.success(),
|
||
"dump failed\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&dump_output.stdout),
|
||
String::from_utf8_lossy(&dump_output.stderr)
|
||
);
|
||
|
||
let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
|
||
assert!(
|
||
!run_json.contains("blob://sha256/"),
|
||
"run export should hydrate blob refs\n{run_json}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dump_exports_blob_refs_and_artifacts_together() {
|
||
let context = test_context!();
|
||
let workspace_dir = context.temp_dir.join("mixed-export");
|
||
fs::create_dir_all(&workspace_dir).unwrap();
|
||
|
||
fs::write(
|
||
workspace_dir.join("mixed-export.fabro"),
|
||
r#"digraph MixedExport {
|
||
graph [goal="Generate oversized command output and artifacts"]
|
||
rankdir=LR
|
||
|
||
start [shape=Mdiamond, label="Start"]
|
||
exit [shape=Msquare, label="Exit"]
|
||
big [shape=parallelogram, label="Big", script="mkdir -p assets/shared && printf exported > assets/shared/report.txt && printf '%*s' 120000 '' | tr ' ' x"]
|
||
|
||
start -> big -> exit
|
||
}
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
fs::write(
|
||
workspace_dir.join("run.toml"),
|
||
r#"_version = 1
|
||
|
||
[workflow]
|
||
graph = "mixed-export.fabro"
|
||
|
||
[run]
|
||
goal = "Generate oversized command output and artifacts"
|
||
|
||
[run.environment]
|
||
id = "local"
|
||
|
||
[environments.local]
|
||
provider = "local"
|
||
|
||
[environments.local.lifecycle]
|
||
preserve = true
|
||
|
||
[run.artifacts]
|
||
include = ["assets/**"]
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let run_id = unique_run_id();
|
||
let mut run_cmd = context.run_cmd();
|
||
run_cmd.current_dir(&workspace_dir);
|
||
run_cmd.timeout(Duration::from_secs(30));
|
||
run_cmd.args([
|
||
"--run-id",
|
||
run_id.as_str(),
|
||
"--environment",
|
||
"local",
|
||
"run.toml",
|
||
]);
|
||
let run_output = run_cmd.output().expect("command should execute");
|
||
assert!(
|
||
run_output.status.success(),
|
||
"workflow run failed\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&run_output.stdout),
|
||
String::from_utf8_lossy(&run_output.stderr)
|
||
);
|
||
|
||
let mut inspect_cmd = context.command();
|
||
inspect_cmd.args(["inspect", "--json", &run_id]);
|
||
let inspect_output = inspect_cmd.output().expect("inspect should execute");
|
||
assert!(
|
||
inspect_output.status.success(),
|
||
"inspect failed\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&inspect_output.stdout),
|
||
String::from_utf8_lossy(&inspect_output.stderr)
|
||
);
|
||
let inspect_json = String::from_utf8(inspect_output.stdout).unwrap();
|
||
assert!(
|
||
inspect_json.contains("blob://sha256/"),
|
||
"inspect output should contain blob refs to exercise hydration\n{inspect_json}"
|
||
);
|
||
|
||
let output_dir = context.temp_dir.join("export-mixed");
|
||
let mut dump_cmd = context.command();
|
||
dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]);
|
||
let dump_output = dump_cmd.output().expect("dump should execute");
|
||
assert!(
|
||
dump_output.status.success(),
|
||
"dump failed\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&dump_output.stdout),
|
||
String::from_utf8_lossy(&dump_output.stderr)
|
||
);
|
||
|
||
let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
|
||
assert!(
|
||
!run_json.contains("blob://sha256/"),
|
||
"run export should hydrate blob refs\n{run_json}"
|
||
);
|
||
assert_eq!(
|
||
fs::read_to_string(
|
||
output_dir.join("artifacts/002-big@1/retry-0001/assets/shared/report.txt")
|
||
)
|
||
.unwrap(),
|
||
"exported"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dump_exports_completed_run_snapshot() {
|
||
let context = test_context!();
|
||
let run = setup_completed_dry_run(&context);
|
||
let output_dir = context.temp_dir.join("export");
|
||
|
||
let mut cmd = context.command();
|
||
cmd.args([
|
||
"dump",
|
||
"--output",
|
||
output_dir.to_str().unwrap(),
|
||
&run.run_id,
|
||
]);
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: true
|
||
exit_code: 0
|
||
----- stdout -----
|
||
Exported 13 files for run [ULID] to [TEMP_DIR]/export
|
||
----- stderr -----
|
||
");
|
||
|
||
assert_snapshot!(dump_file_summary(&output_dir), @"
|
||
checkpoints/0013.json
|
||
checkpoints/0017.json
|
||
checkpoints/0021.json
|
||
events.jsonl
|
||
graph.fabro
|
||
run.json
|
||
run.log
|
||
stages/001-start@1/status.json
|
||
stages/002-run_tests@1/response.md
|
||
stages/002-run_tests@1/status.json
|
||
stages/003-report@1/response.md
|
||
stages/003-report@1/status.json
|
||
stages/004-exit@1/status.json
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn dump_succeeds_when_run_log_is_missing() {
|
||
let context = test_context!();
|
||
let run = setup_seeded_created_dry_run(&context);
|
||
let output_dir = context.temp_dir.join("export-missing-log");
|
||
|
||
let mut cmd = context.command();
|
||
cmd.args([
|
||
"dump",
|
||
"--output",
|
||
output_dir.to_str().unwrap(),
|
||
&run.run_id,
|
||
]);
|
||
let output = cmd.output().expect("dump should execute");
|
||
assert!(
|
||
output.status.success(),
|
||
"dump failed\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
assert!(
|
||
!output_dir.join("run.log").exists(),
|
||
"dump should skip run.log when the server has no run log"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dump_rejects_non_empty_output_dir() {
|
||
let context = test_context!();
|
||
let run = setup_seeded_completed_dry_run(&context);
|
||
let output_dir = context.temp_dir.join("nonempty");
|
||
std::fs::create_dir_all(&output_dir).unwrap();
|
||
std::fs::write(output_dir.join("file.txt"), "x").unwrap();
|
||
|
||
let mut cmd = context.command();
|
||
cmd.args([
|
||
"dump",
|
||
"--output",
|
||
output_dir.to_str().unwrap(),
|
||
&run.run_id,
|
||
]);
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: false
|
||
exit_code: 1
|
||
----- stdout -----
|
||
----- stderr -----
|
||
× output path [TEMP_DIR]/nonempty already exists and is not an empty directory; remove it first or choose a different path
|
||
");
|
||
}
|
||
|
||
fn dump_file_summary(output_dir: &std::path::Path) -> String {
|
||
let mut files: Vec<String> = walkdir::WalkDir::new(output_dir)
|
||
.into_iter()
|
||
.filter_map(Result::ok)
|
||
.filter(|entry| entry.file_type().is_file())
|
||
.map(|entry| {
|
||
entry
|
||
.path()
|
||
.strip_prefix(output_dir)
|
||
.expect("walked file should stay under the output directory")
|
||
.to_string_lossy()
|
||
.replace('\\', "/")
|
||
})
|
||
.collect();
|
||
files.sort();
|
||
files.join("\n") + "\n"
|
||
}
|