From 0b562dadb63144a6a472396ed929e9439032ba87 Mon Sep 17 00:00:00 2001 From: Fabro Date: Sat, 23 May 2026 04:20:28 +0000 Subject: [PATCH] fabro(01KS9BXFGAZ32SGNRE4YJV1354): implement (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KS9BXFGAZ32SGNRE4YJV1354 Fabro-Completed: 7 Fabro-Checkpoint: e2b2831ff67981ed65de4bc91df9086769ab4e1d ⚒️ Generated with [Fabro](https://fabro.sh) --- .fabro/project.toml | 20 +- .fabro/workflows/daytona-medium/workflow.toml | 9 +- docs/public/administration/sandboxing.mdx | 2 +- .../administration/server-configuration.mdx | 17 +- docs/public/agents/mcp.mdx | 9 +- docs/public/api-reference/fabro-api.yaml | 188 +++++---- docs/public/core-concepts/how-fabro-works.mdx | 2 +- docs/public/examples/solitaire.mdx | 13 +- docs/public/execution/devcontainers.mdx | 52 +-- docs/public/execution/environments.mdx | 394 ++++++++---------- docs/public/execution/run-configuration.mdx | 154 +++---- docs/public/human-tools/ssh-access.mdx | 17 +- docs/public/human-tools/vs-code.mdx | 2 +- docs/public/integrations/daytona.mdx | 79 ++-- docs/public/reference/cli.mdx | 10 +- docs/public/reference/server-operations.mdx | 2 +- docs/public/reference/user-configuration.mdx | 2 +- lib/crates/fabro-cli/src/args.rs | 39 +- .../fabro-cli/src/commands/repo/init.rs | 2 +- .../fabro-cli/src/commands/run/overrides.rs | 10 +- .../fabro-cli/src/commands/run/runner.rs | 13 +- .../fabro-cli/src/commands/server/start.rs | 8 +- lib/crates/fabro-cli/src/manifest_args.rs | 8 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 16 +- lib/crates/fabro-config/src/builders.rs | 61 ++- lib/crates/fabro-config/src/defaults.toml | 38 +- lib/crates/fabro-config/src/layers/combine.rs | 17 +- .../fabro-config/src/layers/environment.rs | 134 ++++++ lib/crates/fabro-config/src/layers/mod.rs | 23 +- lib/crates/fabro-config/src/layers/run.rs | 91 +--- .../fabro-config/src/layers/settings.rs | 20 +- lib/crates/fabro-config/src/lib.rs | 17 +- lib/crates/fabro-config/src/parse.rs | 13 +- .../fabro-config/src/resolve/environment.rs | 335 +++++++++++++++ lib/crates/fabro-config/src/resolve/mod.rs | 2 + lib/crates/fabro-config/src/resolve/run.rs | 116 +----- lib/crates/fabro-config/src/tests/combine.rs | 22 +- lib/crates/fabro-config/src/tests/defaults.rs | 11 +- .../fabro-config/src/tests/resolve_root.rs | 23 +- .../fabro-config/src/tests/resolve_run.rs | 258 ++++++++++-- lib/crates/fabro-manifest/src/lib.rs | 106 +++-- lib/crates/fabro-server/src/demo/mod.rs | 78 ++-- lib/crates/fabro-server/src/run_manifest.rs | 394 ++++++++++++------ .../fabro-server/src/run_tool_manifest.rs | 10 +- lib/crates/fabro-server/src/serve.rs | 41 +- lib/crates/fabro-server/src/server.rs | 42 +- .../fabro-server/src/server/handler/graph.rs | 14 +- .../fabro-server/src/server/handler/runs.rs | 21 +- lib/crates/fabro-server/src/server/tests.rs | 22 +- lib/crates/fabro-server/src/test_support.rs | 2 + lib/crates/fabro-server/tests/it/api/tcp.rs | 2 +- lib/crates/fabro-server/tests/it/helpers.rs | 8 +- lib/crates/fabro-store/src/run_state.rs | 26 +- lib/crates/fabro-tool/src/create.rs | 20 +- lib/crates/fabro-types/src/dense.rs | 11 +- lib/crates/fabro-types/src/settings/mod.rs | 10 +- lib/crates/fabro-types/src/settings/run.rs | 220 +++++++--- .../fabro-workflow/src/operations/create.rs | 61 --- .../fabro-workflow/src/operations/start.rs | 189 ++++++--- .../src/.openapi-generator/FILES | 22 +- .../src/models/environment-image-settings.ts | 23 + .../models/environment-lifecycle-settings.ts | 21 + .../src/models/environment-network-mode.ts | 24 ++ .../models/environment-network-settings.ts | 23 + .../src/models/environment-provider.ts | 27 ++ .../models/environment-resources-settings.ts | 21 + .../src/models/environment-settings.ts | 44 ++ .../src/models/environment-volume-settings.ts | 21 + .../fabro-api-client/src/models/index.ts | 18 +- .../src/models/manifest-args.ts | 7 +- .../src/models/run-checkpoint-settings.ts | 2 - .../src/models/run-environment-settings.ts | 45 ++ .../src/models/run-namespace.ts | 8 +- .../src/models/run-projection.ts | 2 +- .../src/models/workflow-settings.ts | 4 + 75 files changed, 2478 insertions(+), 1360 deletions(-) create mode 100644 lib/crates/fabro-config/src/layers/environment.rs create mode 100644 lib/crates/fabro-config/src/resolve/environment.rs create mode 100644 lib/packages/fabro-api-client/src/models/environment-image-settings.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-lifecycle-settings.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-network-mode.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-network-settings.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-provider.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-resources-settings.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-settings.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-volume-settings.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-environment-settings.ts diff --git a/.fabro/project.toml b/.fabro/project.toml index 6731fd4e8..18b684a79 100644 --- a/.fabro/project.toml +++ b/.fabro/project.toml @@ -4,18 +4,26 @@ _version = 1 enabled = true draft = false -[run.sandbox.daytona] -auto_stop_interval = 30 +[run.environment] +id = "fabro-dev" -[run.sandbox.daytona.labels] +[environments.fabro-dev] +provider = "daytona" + +[environments.fabro-dev.lifecycle] +auto_stop = "30m" + +[environments.fabro-dev.labels] repo = "fabro-sh/fabro" -[run.sandbox.daytona.snapshot] -name = "fabro-v11" +[environments.fabro-dev.image] +ref = "fabro-v11" +dockerfile = { path = "Dockerfile" } + +[environments.fabro-dev.resources] cpu = 8 memory = "16GB" disk = "20GB" -dockerfile = { path = "Dockerfile" } # [[run.hooks]] # id = "cargo-fmt" diff --git a/.fabro/workflows/daytona-medium/workflow.toml b/.fabro/workflows/daytona-medium/workflow.toml index ec86fbdba..73032f3d0 100644 --- a/.fabro/workflows/daytona-medium/workflow.toml +++ b/.fabro/workflows/daytona-medium/workflow.toml @@ -3,8 +3,11 @@ _version = 1 [workflow] graph = "workflow.fabro" -[run.sandbox] +[run.environment] +id = "daytona-medium" + +[environments.daytona-medium] provider = "daytona" -[run.sandbox.daytona.snapshot] -name = "daytona-medium" +[environments.daytona-medium.image] +ref = "daytona-medium" diff --git a/docs/public/administration/sandboxing.mdx b/docs/public/administration/sandboxing.mdx index d518c558f..749eedeed 100644 --- a/docs/public/administration/sandboxing.mdx +++ b/docs/public/administration/sandboxing.mdx @@ -9,7 +9,7 @@ Fabro supports three sandbox providers: `local` (no isolation), `docker` (contai ## Network access control -For cloud sandboxes (Daytona), you can control outbound network access with the `network` field in `[run.sandbox.daytona]`. Three modes are available: `"allow_all"` (default), `"block"`, and `{ allow_list = ["..."] }` for CIDR-based egress filtering. +For cloud sandboxes (Daytona), you can control outbound network access with `[environments..network]`. Three modes are available: `"allow_all"` (default), `"block"`, and `"cidr_allow_list"` with an `allow = ["..."]` CIDR list. Server defaults in `settings.toml` apply when a run config doesn't specify `network`. Individual run configs can override the server default. diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index 39cc026a3..0f232a635 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -18,7 +18,7 @@ Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.tom | Scope | Examples | |---|---| | Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | -| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` | +| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.environment]`, `[environments.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` | The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing. @@ -88,13 +88,16 @@ fallbacks = ["gemini", "openai"] [[run.prepare.steps]] script = "npm install" -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" -[run.sandbox.daytona] -auto_stop_interval = 60 +[environments.cloud.lifecycle] +auto_stop = "60m" -[run.sandbox.daytona.labels] +[environments.cloud.labels] team = "platform" [run.checkpoint] @@ -121,7 +124,7 @@ Several `settings.toml` settings can be overridden via `fabro server start` flag | `--foreground` | — | Run in the foreground instead of daemonizing | | `--model` | — | Override default LLM model | | `--provider` | — | Override default LLM provider | -| `--sandbox` | — | Override default sandbox provider | +| `--environment` | — | Override default environment slug | | `--max-concurrent-runs` | `5` | Maximum concurrent run executions | | `--config` | `~/.fabro/settings.toml` | Path to server config file | @@ -228,7 +231,7 @@ On a same-machine setup, `settings.toml` is the shared machine-default layer und On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags. -Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, CLI `-I` / `--input` values replayed from run manifests merge per key at highest precedence, `[run.sandbox.env]` and `[run.sandbox.daytona.labels]` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging. +Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, CLI `-I` / `--input` values replayed from run manifests merge per key at highest precedence, environment `env` and `labels` merge by key, environment `volumes` replace as a whole list, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging. ### `[server.logging]` section diff --git a/docs/public/agents/mcp.mdx b/docs/public/agents/mcp.mdx index 26f64185f..da7868ee4 100644 --- a/docs/public/agents/mcp.mdx +++ b/docs/public/agents/mcp.mdx @@ -230,11 +230,14 @@ graph = "workflow.fabro" [run] goal = "Test the login page" -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" -[run.sandbox.daytona.snapshot] -name = "daytona-medium" +[environments.cloud.image] +ref = "daytona-medium" [run.artifacts] include = ["screenshots/**"] diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 44bfe394c..93297cb25 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -6572,11 +6572,12 @@ components: type: string provider: type: string - sandbox: + environment: type: string + description: Named environment slug to select for the run. docker_image: type: string - description: Per-run Docker sandbox image override. + description: Per-run environment image override. verbose: type: boolean dry_run: @@ -10400,12 +10401,17 @@ components: required: - project - workflow + - environments - run properties: project: $ref: "#/components/schemas/ProjectNamespace" workflow: $ref: "#/components/schemas/WorkflowNamespace" + environments: + type: object + additionalProperties: + $ref: "#/components/schemas/EnvironmentSettings" run: $ref: "#/components/schemas/RunNamespace" @@ -10460,7 +10466,7 @@ components: - clone - run_branch - meta_branch - - sandbox + - environment - notifications - interviews - agent @@ -10500,8 +10506,8 @@ components: $ref: "#/components/schemas/RunBranchSettings" meta_branch: $ref: "#/components/schemas/RunMetaBranchSettings" - sandbox: - $ref: "#/components/schemas/RunSandboxSettings" + environment: + $ref: "#/components/schemas/RunEnvironmentSettings" notifications: type: object additionalProperties: @@ -10670,88 +10676,123 @@ components: push: type: boolean - RunSandboxSettings: + RunEnvironmentSettings: type: object - required: [provider, preserve, stop_on_terminal, devcontainer, env, docker, daytona] + required: [id, provider, image, resources, network, lifecycle, labels, volumes, env] properties: + id: + type: string provider: - $ref: "#/components/schemas/SandboxProvider" - preserve: - type: boolean - stop_on_terminal: - type: boolean - devcontainer: - type: boolean + $ref: "#/components/schemas/EnvironmentProvider" + image: + $ref: "#/components/schemas/EnvironmentImageSettings" + resources: + $ref: "#/components/schemas/EnvironmentResourcesSettings" + network: + $ref: "#/components/schemas/EnvironmentNetworkSettings" + lifecycle: + $ref: "#/components/schemas/EnvironmentLifecycleSettings" + labels: + $ref: "#/components/schemas/StringMap" + volumes: + type: array + items: + $ref: "#/components/schemas/EnvironmentVolumeSettings" env: type: object additionalProperties: $ref: "#/components/schemas/InterpString" - docker: - oneOf: - - $ref: "#/components/schemas/DockerSettings" - - type: "null" - daytona: - oneOf: - - $ref: "#/components/schemas/DaytonaSettings" - - type: "null" - DockerSettings: + EnvironmentSettings: type: object - required: [image, network_mode, memory_limit, cpu_quota, env_vars] + required: [provider, image, resources, network, lifecycle, labels, volumes, env] properties: + provider: + $ref: "#/components/schemas/EnvironmentProvider" image: - type: string - network_mode: - type: ["string", "null"] - memory_limit: - type: ["integer", "null"] - format: int64 - cpu_quota: - type: ["integer", "null"] - format: int64 - env_vars: + $ref: "#/components/schemas/EnvironmentImageSettings" + resources: + $ref: "#/components/schemas/EnvironmentResourcesSettings" + network: + $ref: "#/components/schemas/EnvironmentNetworkSettings" + lifecycle: + $ref: "#/components/schemas/EnvironmentLifecycleSettings" + labels: + $ref: "#/components/schemas/StringMap" + volumes: + type: array + items: + $ref: "#/components/schemas/EnvironmentVolumeSettings" + env: type: object additionalProperties: $ref: "#/components/schemas/InterpString" - DaytonaSettings: - type: object - required: [auto_stop_interval, labels, snapshot, network] - properties: - auto_stop_interval: - type: ["integer", "null"] - format: int32 - labels: - $ref: "#/components/schemas/StringMap" - snapshot: - oneOf: - - $ref: "#/components/schemas/DaytonaSnapshotSettings" - - type: "null" - network: - oneOf: - - $ref: "#/components/schemas/DaytonaNetworkLayer" - - type: "null" + EnvironmentProvider: + description: Desired environment provider. + type: string + enum: [local, docker, daytona] - DaytonaSnapshotSettings: + EnvironmentImageSettings: type: object - required: [name, cpu, memory_gb, disk_gb, dockerfile] + required: [ref, dockerfile] properties: - name: - type: string - cpu: - type: ["integer", "null"] - format: int32 - memory_gb: - type: ["integer", "null"] - format: int32 - disk_gb: - type: ["integer", "null"] - format: int32 + ref: + type: ["string", "null"] dockerfile: oneOf: - $ref: "#/components/schemas/DockerfileSource" - type: "null" + EnvironmentResourcesSettings: + type: object + required: [cpu, memory, disk] + properties: + cpu: + type: ["integer", "null"] + format: int32 + memory: + type: ["string", "null"] + disk: + type: ["string", "null"] + + EnvironmentNetworkSettings: + type: object + required: [mode, allow] + properties: + mode: + $ref: "#/components/schemas/EnvironmentNetworkMode" + allow: + type: array + items: + type: string + + EnvironmentNetworkMode: + type: string + enum: [allow_all, block, cidr_allow_list] + + EnvironmentLifecycleSettings: + type: object + required: [preserve, stop_on_terminal, auto_stop] + properties: + preserve: + type: boolean + stop_on_terminal: + type: boolean + auto_stop: + type: ["string", "null"] + + EnvironmentVolumeSettings: + type: object + required: [id, mount_path, subpath] + properties: + id: + type: string + mount_path: + type: string + subpath: + type: ["string", "null"] + DockerfileSource: oneOf: - $ref: "#/components/schemas/DockerfileSourceInline" @@ -10777,23 +10818,6 @@ components: path: type: string - DaytonaNetworkLayer: - description: Daytona network access policy. - oneOf: - - type: string - enum: [block, allow_all] - - type: object - required: [allow_list] - properties: - allow_list: - type: object - required: [allow_list] - properties: - allow_list: - type: array - items: - type: string - NotificationRouteSettings: type: object required: [enabled, provider, events, slack] @@ -11651,4 +11675,4 @@ components: login: type: string description: User's login identifier (e.g. GitHub username). - example: octocat \ No newline at end of file + example: octocat diff --git a/docs/public/core-concepts/how-fabro-works.mdx b/docs/public/core-concepts/how-fabro-works.mdx index 9a7c34def..fd9544ccf 100644 --- a/docs/public/core-concepts/how-fabro-works.mdx +++ b/docs/public/core-concepts/how-fabro-works.mdx @@ -75,7 +75,7 @@ Node handlers execute tools (bash commands, file edits) inside a **sandbox**. Fa | `local` | Tools run directly on the host machine | | `daytona` | Tools run in a cloud VM with SSH access | -The sandbox is configured per-run via CLI flags (`--sandbox docker`) or the run config TOML. See [Environments](/execution/environments) for details on each provider. +Runs select a named environment via CLI flags (`--environment ci`) or run config TOML (`[run.environment] id = "ci"`). Fabro then creates a concrete sandbox from that environment. See [Environments](/execution/environments) for details. ## Events and observability diff --git a/docs/public/examples/solitaire.mdx b/docs/public/examples/solitaire.mdx index 4a254837a..b13c699b2 100644 --- a/docs/public/examples/solitaire.mdx +++ b/docs/public/examples/solitaire.mdx @@ -271,15 +271,20 @@ fallbacks = ["openai", "gemini"] [[run.prepare.steps]] script = "python3 -m venv .venv && . .venv/bin/activate && pip install pytest curses" -[run.sandbox] +[run.environment] +id = "python-dev" + +[environments.python-dev] provider = "daytona" -[run.sandbox.daytona.snapshot] -name = "python-dev" +[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] cpu = 4 memory = "8GB" disk = "20GB" -dockerfile = "FROM python:3.12-slim\nRUN apt-get update && apt-get install -y git libncurses-dev" ``` ```bash diff --git a/docs/public/execution/devcontainers.mdx b/docs/public/execution/devcontainers.mdx index 72916a59f..b807ad9fe 100644 --- a/docs/public/execution/devcontainers.mdx +++ b/docs/public/execution/devcontainers.mdx @@ -1,45 +1,33 @@ --- title: "Devcontainers" -description: "Run Fabro workflows inside development containers" +description: "Using repository devcontainer metadata with Fabro environments" --- -Fabro can use your project's [devcontainer](https://containers.dev/) configuration to set up sandbox environments. When enabled, Fabro resolves `devcontainer.json` from the repository, uses its Dockerfile to build the Daytona sandbox snapshot, runs lifecycle hooks inside the sandbox, and merges devcontainer environment variables into the sandbox environment. +Named environments are the supported configuration surface for run execution. Define reusable environments under `[environments.]` and select one with `[run.environment] id = "..."`. -## Enabling devcontainer support +Devcontainer-specific run configuration (`[run.sandbox] devcontainer = true`) has been removed with the named environments configuration break. To use a devcontainer-style image today, build or reference it through an environment image: -Set `devcontainer = true` in the `[run.sandbox]` section of your run config: +```toml +[run.environment] +id = "dev" -```toml title="run.toml" -_version = 1 +[environments.dev] +provider = "docker" -[workflow] -graph = "workflow.fabro" - -[run.sandbox] -provider = "daytona" -devcontainer = true +[environments.dev.image] +ref = "ghcr.io/acme/project-devcontainer:latest" ``` -Fabro looks for `.devcontainer/devcontainer.json` in the repository root. If found, it extracts the Dockerfile, lifecycle commands, and environment variables from the configuration. +For Daytona snapshot creation, provide a Dockerfile path on the selected environment: -## What Fabro uses from devcontainer.json +```toml +[run.environment] +id = "cloud-dev" -| Field | How Fabro uses it | -|---|---| -| `build.dockerfile` | Used as the Dockerfile for the Daytona sandbox snapshot. A deterministic snapshot name is generated from a hash of the Dockerfile content. | -| `onCreateCommand` | Runs inside the sandbox after it's created | -| `postCreateCommand` | Runs after `onCreateCommand` completes | -| `postStartCommand` | Runs after the sandbox starts | -| `containerEnv` | Merged into sandbox environment variables (TOML `[run.sandbox.env]` values take precedence on key collisions) | +[environments.cloud-dev] +provider = "daytona" -Lifecycle commands (`onCreateCommand`, `postCreateCommand`, `postStartCommand`) execute sequentially inside the sandbox. If any command fails, the run aborts before the workflow starts. - -## Dockerfile limitations - -Fabro detects and reports unsupported `COPY` and `ADD` instructions in devcontainer base Dockerfiles. These instructions reference files from the build context, which isn't available when building Daytona snapshots. If your Dockerfile uses `COPY` or `ADD`, you'll need to restructure it to use `RUN` commands that fetch files at build time (e.g., via `curl` or `wget`). - -## Interaction with other sandbox settings - -When `devcontainer = true`, the devcontainer Dockerfile overrides any `snapshot.dockerfile` setting in `[run.sandbox.daytona]`. Other Daytona settings (`cpu`, `memory`, `disk`, `auto_stop_interval`, `labels`) still apply. - -Environment variables from `containerEnv` in the devcontainer config are merged with `[run.sandbox.env]` from the TOML config. On key collisions, the TOML config wins. +[environments.cloud-dev.image] +ref = "project-dev" +dockerfile = { path = ".devcontainer/Dockerfile" } +``` diff --git a/docs/public/execution/environments.mdx b/docs/public/execution/environments.mdx index f8f700128..c75686e24 100644 --- a/docs/public/execution/environments.mdx +++ b/docs/public/execution/environments.mdx @@ -1,265 +1,197 @@ --- title: "Environments" -description: "Sandbox providers for workflow execution" +description: "Reusable named execution environments for workflow runs" --- -When an agent runs a shell command, edits a file, or searches code, it does so inside a **sandbox**. The sandbox is the execution environment for all tool operations — it controls where commands run, which files are visible, and how much isolation exists between the agent and the host. +Fabro separates **environments** from **sandboxes**: -Fabro supports three sandbox providers. Each one implements the same interface (file I/O, command execution, grep, glob), so workflows run identically regardless of which provider you choose. The difference is in where and how the tools execute. +- An **environment** is reusable desired configuration: provider, image, resources, network, lifecycle, labels, volumes, and environment variables. +- A **sandbox** is the concrete runtime instance Fabro creates for a run from the selected environment. -| Provider | Runs on | Use case | Status | -|---|---|---|---| -| `local` | Host machine | Development, trusted workflows | Available | -| `docker` | Docker container | Reproducible environments, untrusted code | Available | -| `daytona` | Cloud VM | CI/CD, team-shared runs, SSH debugging | Available | +Runs select environments by slug: -## Choosing a provider - -Set the sandbox provider via CLI flag, [run config TOML](/execution/run-configuration), or server defaults: - -```bash -# CLI flag -fabro run workflow.fabro --sandbox local -fabro run workflow.fabro --sandbox docker -fabro run workflow.fabro --sandbox daytona +```toml title="workflow.toml" +[run.environment] +id = "fabro-dev" ``` -```toml title="run.toml" -# Run config TOML -[run.sandbox] -provider = "daytona" +Environment catalogs can live in `settings.toml`, `.fabro/project.toml`, or `workflow.toml`, and merge through the normal settings precedence. The built-in default is `default`, a Docker environment using `buildpack-deps:noble`. + +## Defining environments + +```toml title="workflow.toml" +[run.environment] +id = "fabro-dev" + +[environments.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] +cpu = 8 +memory = "16GB" +disk = "20GB" + +[environments.fabro-dev.network] +mode = "cidr_allow_list" # allow_all | block | cidr_allow_list +allow = ["10.0.0.0/8"] + +[environments.fabro-dev.lifecycle] +preserve = false +stop_on_terminal = true +auto_stop = "30m" + +[environments.fabro-dev.labels] +repo = "fabro-sh/fabro" + +[[environments.fabro-dev.volumes]] +id = "vol-agent-state" +mount_path = "/home/daytona/agent-state" +subpath = "auth" + +[environments.fabro-dev.env] +NODE_ENV = "development" ``` -The precedence order is: CLI flag > run config TOML > server defaults > built-in default (`docker`). +Run-level overrides are sparse and apply only to the selected environment: -## Local +```toml title="workflow.toml" +[run.environment] +id = "fabro-dev" -The local sandbox runs all tool operations directly on the host machine. Use it for trusted workflows, local development, or runs that must operate directly on the current working tree. +[run.environment.resources] +memory = "32GB" -### How it works - -- **Working directory** — Set to the current directory (or `directory` from the run config). Fabro creates it if it doesn't exist. -- **Commands** — Executed via `/bin/bash -c` in the working directory. -- **File operations** — Read and write directly to the host filesystem. Relative paths resolve against the working directory. -- **Cleanup** — No-op. Local sandbox doesn't create or destroy anything on cleanup. - -### Environment variable filtering - -The local sandbox filters sensitive environment variables before passing them to commands. Variables ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL` are stripped. A safelist of common variables (`PATH`, `HOME`, `USER`, `SHELL`, `LANG`, `TERM`, `TMPDIR`, `GOPATH`, `CARGO_HOME`, `NVM_DIR`) is always passed through. - - -The local sandbox offers no isolation. Agents can read and modify any file on the host. Use `docker` or `daytona` when running untrusted workflows or when you need a reproducible environment. - - -## Docker - -The Docker sandbox runs all tool operations inside a Docker container. Docker is the built-in default runtime provider. Docker runs use a provider-owned workspace in the container; when the run has a GitHub origin, Fabro clones that repository into the workspace. - -### Prerequisites - -- Docker Engine running on the host -- The configured image available locally, or a Docker client that can pull it -- GitHub credentials configured when cloning private repositories - -### How it works - -- **Container lifecycle** — On `initialize()`, Fabro pulls the image (if needed), creates a container with `sleep infinity`, and starts it. On `cleanup()`, Fabro stops and removes the container. -- **Working directory** — Docker runs use a provider-owned workspace inside the container. When a run has a GitHub origin, Fabro clones that repository into the workspace instead of bind-mounting the host source tree. -- **Git clone** — Docker and Daytona accept GitHub origins for automatic cloning. If the run has a present non-GitHub origin, set `[run.clone] enabled = false` or switch to `local`. -- **Commands** — Executed via `docker exec` with `/bin/bash -c` inside the container. Timeout and cancellation are supported. -- **File writes** — Use the Docker API's tar upload to avoid shell escaping issues with special characters. -- **Platform detection** — The container's `uname -r` is cached at startup. - -### Configuration - -Configure Docker through `[run.sandbox.docker]`: - -```toml title="run.toml" -[run.sandbox] -provider = "docker" - -[run.sandbox.docker] -image = "buildpack-deps:noble" -network_mode = "bridge" -memory_limit = "4GB" -cpu_quota = 200000 -``` - -| Setting | Default | Description | -|---|---|---| -| `image` | `buildpack-deps:noble` | Docker image to use | -| `network_mode` | `bridge` | Docker network mode | -| `memory_limit` | `4GB` | Memory limit | -| `cpu_quota` | `200000` | CPU quota (microseconds per 100ms period) | - -Set `[run.clone] enabled = false` to start with an empty provider workspace instead of cloning the run's GitHub origin. Use [prepare steps](/execution/run-configuration#runprepare) to clone or create any files the workflow needs. - -### Preserving the container - -By default, the container is destroyed when the run finishes. To keep it alive for debugging: - -```bash -fabro run workflow.fabro --sandbox docker --preserve-sandbox -``` - -Or in the run config: - -```toml title="run.toml" -[run.sandbox] -provider = "docker" +[run.environment.lifecycle] preserve = true ``` -When preserved, Fabro prints the container ID so you can reconnect with `docker exec -it bash`. +`env` and `labels` merge by key. `volumes` replace as a whole list when set at a higher-precedence layer. + +## Selecting an environment from the CLI + +Use `--environment` with an environment slug: + +```bash +fabro run workflow.fabro --environment fabro-dev +fabro preflight workflow.fabro --environment ci +fabro server start --environment default +``` + +`--preserve-sandbox` still controls the concrete runtime instance lifecycle for a run. Runtime commands such as `fabro sandbox ssh` keep the word "sandbox" because they operate on an already-created runtime instance. + +## Built-in default + +```toml +[run.environment] +id = "default" + +[environments.default] +provider = "docker" + +[environments.default.image] +ref = "buildpack-deps:noble" + +[environments.default.resources] +cpu = 2 +memory = "4GB" + +[environments.default.lifecycle] +preserve = false +stop_on_terminal = true +``` + +## Provider mappings + +| Environment field | Local | Docker | Daytona | +|---|---|---|---| +| `image.ref` | Ignored | Docker image | Snapshot name | +| `image.dockerfile` | Ignored | Warning; ignored | Snapshot Dockerfile; requires `image.ref` | +| `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 | +| `network.mode = "allow_all"` | Host network | Docker default bridge | Daytona allow-all | +| `network.mode = "block"` | Error | Docker `none` network | 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 | +| `env` | Process environment overlay | Container environment | Sandbox environment | + +## Local + +`local` runs tools directly in the resolved working directory. It offers no filesystem or network isolation, so use it only for trusted workflows. + +```toml +[run.environment] +id = "host" + +[environments.host] +provider = "local" +``` + +Fabro hard-errors if a local environment asks for blocked or CIDR-restricted networking because the provider cannot enforce it. + +## Docker + +Docker runs tools inside a container created from `image.ref`. Docker is the built-in default provider. + +```toml +[run.environment] +id = "ci" + +[environments.ci] +provider = "docker" + +[environments.ci.image] +ref = "buildpack-deps:noble" + +[environments.ci.resources] +cpu = 2 +memory = "4GB" + +[environments.ci.network] +mode = "block" +``` + +Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace. Set `[run.clone] enabled = false` to start with an empty workspace. ## Daytona -The Daytona sandbox runs all tool operations inside a cloud-hosted VM managed by [Daytona](https://daytona.io). It provides full machine-level isolation, automatic git cloning, and SSH access for debugging. +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. -### Prerequisites +```toml +[run.environment] +id = "cloud" -- A `DAYTONA_API_KEY` environment variable -- GitHub access configured via `fabro install` or `gh auth login` (for private repository cloning) - -The Daytona API key must include `write:snapshots`, `delete:snapshots`, `write:sandboxes`, and `delete:sandboxes`. `fabro install`, `fabro secret set DAYTONA_API_KEY`, and `fabro doctor` validate these scopes before the first run reaches sandbox creation. - -### How it works - -- **Sandbox lifecycle** — On `initialize()`, Fabro creates a Daytona sandbox (from an image or a snapshot), clones the run's GitHub origin into it when one is available, and waits until it's ready. On `cleanup()`, the sandbox is deleted. -- **Working directory** — Fixed at `/home/daytona/workspace`. GitHub-origin runs clone the repository there automatically. -- **Git clone** — Fabro detects the run manifest's GitHub origin URL and branch, converts SSH URLs to HTTPS, and clones into the sandbox. For private repositories, Fabro uses a GitHub App Installation Access Token scoped to the specific repository. Public repositories are cloned without credentials. Set `[run.clone] enabled = false` to create an empty workspace instead. -- **Commands** — Executed via the Daytona process API. Commands are base64-encoded and piped through `sh` to support pipes, environment variables, and other shell features. -- **Ephemeral** — Sandboxes are created with `ephemeral: true` and a unique timestamped name (e.g. `fabro-20260305-142301-a3f2`). - -### Snapshots - -Snapshots let you pre-build an environment image so each run starts with dependencies already installed. If the named snapshot doesn't exist and a `dockerfile` is provided, Fabro creates it automatically and polls until it's ready (up to 10 minutes). - -```toml title="run.toml" -[run.sandbox] +[environments.cloud] provider = "daytona" -[run.sandbox.daytona] -auto_stop_interval = 60 +[environments.cloud.image] +ref = "rust-dev" +dockerfile = { path = "Dockerfile" } -[run.sandbox.daytona.snapshot] -name = "rust-dev" +[environments.cloud.resources] cpu = 4 memory = "8GB" disk = "20GB" -dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep" -# Or reference a Dockerfile beside this TOML file: -# dockerfile = { path = "./Dockerfile" } + +[environments.cloud.lifecycle] +auto_stop = "30m" + +[environments.cloud.network] +mode = "cidr_allow_list" +allow = ["208.80.154.232/32", "10.0.0.0/8"] ``` -| Field | Description | -|---|---| -| `name` | Snapshot identifier. Reused across runs if it already exists. | -| `cpu` | CPU cores for the snapshot VM. | -| `memory` | Memory in GB. | -| `disk` | Disk in GB. | -| `dockerfile` | Dockerfile content or `{ path = "..." }` reference for building the snapshot. Required when creating a new snapshot. Paths resolve relative to the TOML file that declares them. | +Daytona volumes reference existing provider-managed volumes: -If the snapshot already exists and is in `Active` state, Fabro uses it directly. If it's in `Building` or `Pending` state, Fabro polls with exponential backoff until it's ready. - -### Volumes - -Mount existing Daytona volumes into each sandbox at creation time: - -```toml title="run.toml" -[run.sandbox] -provider = "daytona" - -[[run.sandbox.daytona.volumes]] -volume_id = "vol-agent-state" +```toml +[[environments.cloud.volumes]] +id = "vol-agent-state" mount_path = "/home/daytona/agent-state" subpath = "agent-auth" ``` - -Fabro does not create or manage Daytona volumes. Create the volume in Daytona first, then reference its `volume_id` from the run config. Use `subpath` when a shared volume should expose only one prefix to the sandbox. - -### Labels - -Attach key-value labels to sandboxes for filtering and identification in the Daytona dashboard: - -```toml title="run.toml" -[run.sandbox.daytona.labels] -project = "fabro" -env = "ci" -team = "platform" -``` - -When using server defaults, labels are merged — run config labels override default labels on key collisions. - -### SSH access - -Connect to a running Daytona sandbox via SSH for live debugging: - -```bash -fabro sandbox ssh -``` - -This creates temporary SSH credentials (valid for 60 minutes) and connects directly. - -### Preserving the sandbox - -Like Docker, Daytona sandboxes are destroyed on cleanup by default. Use `--preserve-sandbox` to keep them alive: - -```bash -fabro run workflow.fabro --sandbox daytona --preserve-sandbox -``` - -Fabro prints the sandbox name so you can find it in the [Daytona dashboard](https://app.daytona.io/dashboard/sandboxes). - -### Auto-stop - -The `auto_stop_interval` setting (in minutes) tells Daytona to stop the sandbox after a period of inactivity. This saves costs for long-running sandboxes that may sit idle: - -```toml title="run.toml" -[run.sandbox.daytona] -auto_stop_interval = 30 -``` - -## Sandboxing - -Sandboxes isolate agent execution from the host machine. When an agent runs a shell command, edits a file, or searches code, it does so inside a sandbox — preventing unintended side effects and providing a reproducible environment for each run. - -### Filesystem - -Each provider offers a different level of filesystem isolation: - -| Provider | Isolation | What agents can access | -|---|---|---| -| `local` | None | The entire host filesystem. Agents can read and modify any file. | -| `docker` | Container-level | The provider-owned workspace (`/workspace` by default) and whatever is in the container image. Host files are not bind-mounted into the container. | -| `daytona` | Full machine | A cloud VM with the repository cloned into `/home/daytona/workspace`. The host filesystem is completely inaccessible. | - -For `local`, Fabro filters sensitive environment variables (those ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL`) but does not restrict file access. Use `docker` or `daytona` when running untrusted workflows. - -### Network - -Each provider handles outbound network access differently: - -| Provider | Default | Controls | -|---|---|---| -| `local` | Full access | No network isolation. Agents have the same network access as the host. | -| `docker` | Bridge network | Set via the `network_mode` config option. Supports all Docker network modes (`bridge`, `none`, `host`, etc.). | -| `daytona` | Full access | Configurable via the `network` setting with three modes: `"allow_all"`, `"block"`, or CIDR-based allow lists. | - -For Daytona, network access is configured in the `[run.sandbox.daytona]` section: - -```toml title="run.toml" -# Block all egress -[run.sandbox.daytona] -network = "block" - -# Allow only specific CIDRs -[run.sandbox.daytona] -network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] } -``` - -When using server defaults, the run config `network` overrides the server default. If neither specifies `network`, Daytona's own default (full access) applies. - -## Safety guardrails - -Regardless of which provider you use, Fabro applies a **read-before-write** guardrail. Agents must read a file (via `read_file` or `grep`) before they can modify it with `write_file` or `delete_file`. Writing to new files that don't yet exist is always allowed. This prevents agents from blindly overwriting files they haven't inspected. diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index 14891af32..cedc46904 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -56,25 +56,30 @@ script = "git clone https://github.com/fabro-sh/fabro repo" [[run.prepare.steps]] script = "cd repo && npm install" -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" + +[environments.cloud.lifecycle] preserve = false +auto_stop = "60m" -[run.sandbox.daytona] -auto_stop_interval = 60 - -[run.sandbox.daytona.labels] +[environments.cloud.labels] project = "fabro" env = "ci" -[run.sandbox.daytona.snapshot] -name = "node-20" +[environments.cloud.image] +ref = "node-20" +dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git" + +[environments.cloud.resources] cpu = 4 memory = "8GB" disk = "20GB" -dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git" -[run.sandbox.env] +[environments.cloud.env] API_KEY = "{{ env.MY_API_KEY }}" NODE_ENV = "production" @@ -222,94 +227,66 @@ push = true | `enabled` | When `false`, Fabro skips metadata branch snapshots. | | `push` | When `false`, Fabro writes metadata snapshots locally but does not push `fabro/meta/` to the remote. | -### `[run.sandbox]` +### `[run.environment]` and `[environments.]` -Configure how agent tools (bash, file edits) are executed. +Runs select a reusable named environment by slug. Environment catalogs can be +defined in `settings.toml`, `.fabro/project.toml`, or `workflow.toml`. ```toml title="run.toml" -[run.sandbox] -provider = "docker" +[run.environment] +id = "ci" + +[environments.ci] +provider = "docker" # local | docker | daytona + +[environments.ci.image] +ref = "buildpack-deps:noble" + +[environments.ci.resources] +cpu = 2 +memory = "4GB" + +[environments.ci.lifecycle] preserve = true +stop_on_terminal = true + +[environments.ci.env] +NODE_ENV = "production" ``` -| Field | Description | -|---|---| -| `provider` | Sandbox mode: `docker` (default), `local`, or `daytona`. | -| `preserve` | When `true`, keep the sandbox alive after the run finishes. Useful for debugging. | -| `devcontainer` | When `true`, use the repo's `devcontainer.json` to configure the sandbox. See [Devcontainers](/execution/devcontainers). | +Sparse run-level overrides live under `[run.environment.*]` and apply to the +selected environment only: -#### `[run.sandbox.docker]` - -Additional settings when using the Docker sandbox: - -```toml title="run.toml" -[run.sandbox] -provider = "docker" - -[run.sandbox.docker] -image = "buildpack-deps:noble" -network_mode = "bridge" -memory_limit = "4GB" -cpu_quota = 200000 -``` - -| Field | Description | -|---|---| -| `image` | Docker image used for the run container. Defaults to `buildpack-deps:noble`. | -| `network_mode` | Docker network mode. Defaults to `bridge`. | -| `memory_limit` | Memory limit using human-readable units such as `"4GB"` or `"512MiB"`. | -| `cpu_quota` | Docker CPU quota in microseconds per 100ms period. Defaults to `200000`. | -| `env_vars` | Provider-level environment variables passed to the Docker container. For workflow tool execution, prefer `[run.sandbox.env]`. | - -#### `[run.sandbox.daytona]` - -Additional settings when using the Daytona cloud sandbox: - -```toml title="run.toml" -[run.sandbox.daytona] -auto_stop_interval = 60 - -[run.sandbox.daytona.labels] -project = "fabro" -env = "staging" - -[[run.sandbox.daytona.volumes]] -volume_id = "vol-agent-state" -mount_path = "/home/daytona/agent-state" -subpath = "agent-auth" - -[run.sandbox.daytona.snapshot] -name = "my-snapshot" -cpu = 4 +```toml +[run.environment.resources] memory = "8GB" -disk = "20GB" -dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update" -# Or reference an external Dockerfile: -# dockerfile = { path = "./Dockerfile" } ``` | Field | Description | |---|---| -| `auto_stop_interval` | Minutes of inactivity before the sandbox auto-stops. | -| `labels` | Key-value labels attached to the sandbox for filtering and identification. Labels merge across layers (sticky merge-by-key). | -| `volumes` | Existing Daytona volumes to mount at sandbox creation. Each entry requires `volume_id` and `mount_path`; `subpath` is optional. Fabro does not create or manage volume lifecycle. | -| `snapshot.name` | Snapshot name to create or use for the sandbox. | -| `snapshot.cpu` | CPU cores for the snapshot (integer). | -| `snapshot.memory` | Memory size using human-readable units: `"8GB"`, `"16GiB"`, or bare integers that default to GB. | -| `snapshot.disk` | Disk size using the same units as `memory`. | -| `snapshot.dockerfile` | Dockerfile content (inline string) or path (`{ path = "..." }`) for building the snapshot image. Paths are resolved relative to the TOML file's directory. | -| `network` | Network access mode: `"allow_all"` (default), `"block"`, or `{ allow_list = ["..."] }`. See [Sandboxing](/administration/sandboxing#network-access-control). | +| `run.environment.id` | Environment slug to select. Defaults to `default`. | +| `environments..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`. | +| `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. | +| `lifecycle.preserve` | Keep the created sandbox after the run finishes. | +| `lifecycle.stop_on_terminal` | Stop the sandbox when the run reaches a terminal state. | +| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. | +| `labels` | Provider labels. Merge by key across layers. | +| `volumes` | Provider volume hints. Lists replace wholesale across layers. | +| `env` | Environment variables passed to command and agent execution. Merge by key across layers. | -#### Local sandbox +When `provider = "local"`, Fabro runs directly in the resolved working +directory. If you want local isolation, create or enter a separate clone or Git +worktree yourself. -When `run.sandbox.provider = "local"`, Fabro runs directly in the resolved working directory. If you want local isolation, create or enter a separate clone or Git worktree yourself, then run with `--sandbox local`. - -#### `[run.sandbox.env]` - -Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment references using `{{ env.VARNAME }}` syntax: +Environment variable values can be literal strings or host environment +references using `{{ env.VARNAME }}` syntax: ```toml title="run.toml" -[run.sandbox.env] +[environments.ci.env] API_KEY = "{{ env.MY_API_KEY }}" NODE_ENV = "production" SERVICE_URL = "https://api.{{ env.REGION }}.example.com" @@ -321,7 +298,7 @@ SERVICE_URL = "https://api.{{ env.REGION }}.example.com" | `"{{ env.VARNAME }}"` | Whole-value reference resolved from the host environment at consumption time | | `"prefix-{{ env.X }}-suffix"` | Substring interpolation; multiple tokens per string are supported | -Missing host variables produce a hard error pointing at the specific field and unresolved token. `run.sandbox.env` is a sticky merge-by-key map: entries from all layers combine, with higher-precedence layers overriding individual keys. +Missing host variables produce a hard error pointing at the specific field and unresolved token. ### `[run.integrations.github.permissions]` @@ -514,7 +491,7 @@ Settings can come from multiple sources. Fabro resolves them in this order (firs | Source | Priority | |---|---| | Node-level [stylesheet](/workflows/stylesheets) | Highest | -| CLI flags (`--model`, `--provider`, `--sandbox`) | | +| CLI flags (`--model`, `--provider`, `--environment`) | | | Run config TOML (`workflow.toml` or equivalent) | | | Project defaults (`.fabro/project.toml`) | | | Machine defaults (`~/.fabro/settings.toml`) | | @@ -535,14 +512,17 @@ _version = 1 [run.model] name = "claude-sonnet-4-5" -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" -[run.sandbox.daytona.snapshot] -name = "my-project-snapshot" +[environments.cloud.image] +ref = "my-project-snapshot" ``` -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, `run.sandbox.env` sticky-merges by key, and `run.prepare.steps` replaces whole-list. +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. ### Machine defaults diff --git a/docs/public/human-tools/ssh-access.mdx b/docs/public/human-tools/ssh-access.mdx index 41fd8934f..b02aca0fb 100644 --- a/docs/public/human-tools/ssh-access.mdx +++ b/docs/public/human-tools/ssh-access.mdx @@ -29,20 +29,23 @@ fabro sandbox ssh --ttl 120 By default, Daytona sandboxes are destroyed when the workflow finishes. To keep the sandbox running after the workflow completes — so you can continue debugging — pass `--preserve-sandbox`: ```bash -fabro run workflow.fabro --sandbox daytona --preserve-sandbox +fabro run workflow.fabro --environment cloud --preserve-sandbox ``` Without `--preserve-sandbox`, the SSH session is terminated when the run ends and the sandbox is cleaned up. -You can also set `auto_stop_interval` in your run config to control how long an idle sandbox stays alive: +You can also set `lifecycle.auto_stop` in your environment to control how long an idle sandbox stays alive: ```toml title="run.toml" -[run.sandbox] -provider = "daytona" -preserve = true +[run.environment] +id = "cloud" -[run.sandbox.daytona] -auto_stop_interval = 60 +[environments.cloud] +provider = "daytona" + +[environments.cloud.lifecycle] +preserve = true +auto_stop = "60m" ``` ## What you can do over SSH diff --git a/docs/public/human-tools/vs-code.mdx b/docs/public/human-tools/vs-code.mdx index 0f9affc4e..c20052103 100644 --- a/docs/public/human-tools/vs-code.mdx +++ b/docs/public/human-tools/vs-code.mdx @@ -19,7 +19,7 @@ VS Code remote access requires [SSH access](/human-tools/ssh-access), which is o 1. Start a workflow with a preserved Daytona sandbox: ```bash - fabro run workflow.fabro --sandbox daytona --preserve-sandbox + fabro run workflow.fabro --environment cloud --preserve-sandbox ``` 2. Use `fabro sandbox ssh` to get the connection command: diff --git a/docs/public/integrations/daytona.mdx b/docs/public/integrations/daytona.mdx index b1120febb..35e514bbf 100644 --- a/docs/public/integrations/daytona.mdx +++ b/docs/public/integrations/daytona.mdx @@ -24,28 +24,34 @@ The Daytona key must include the snapshot and sandbox scopes Fabro uses to creat ## Configuration -Set the sandbox provider in your run config TOML or via CLI flag: +Select a Daytona environment in your run config TOML or via CLI flag: ```bash -fabro run workflow.fabro --sandbox daytona +fabro run workflow.fabro --environment cloud ``` ```toml title="run.toml" -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" ``` A full configuration example with all Daytona-specific options: ```toml title="run.toml" -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" + +[environments.cloud.lifecycle] preserve = false +auto_stop = "60m" -[run.sandbox.daytona] -auto_stop_interval = 60 - -[run.sandbox.daytona.labels] +[environments.cloud.labels] project = "fabro" env = "staging" team = "platform" @@ -58,17 +64,19 @@ back to Fabro-managed runs. User-provided values for these reserved keys are overwritten. -[run.sandbox.daytona.snapshot] -name = "rust-dev" -cpu = 4 -memory = "8GB" -disk = "20GB" +[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" } + +[environments.cloud.resources] +cpu = 4 +memory = "8GB" +disk = "20GB" ``` -See [Server Configuration](/administration/server-configuration) for the full reference on all sandbox fields and server defaults. +See [Environments](/execution/environments) for the full reference on all environment fields and server defaults. ### Network access control @@ -76,16 +84,17 @@ Control outbound network access with the `network` field. Three modes are availa ```toml title="run.toml" # Full access (default) -[run.sandbox.daytona] -network = "allow_all" +[environments.cloud.network] +mode = "allow_all" # Block all egress -[run.sandbox.daytona] -network = "block" +[environments.cloud.network] +mode = "block" # CIDR-based allow list -[run.sandbox.daytona] -network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] } +[environments.cloud.network] +mode = "cidr_allow_list" +allow = ["208.80.154.232/32", "10.0.0.0/8"] ``` Use `"block"` or a CIDR allow list when running untrusted or generated code to prevent agents from making arbitrary network requests. @@ -95,12 +104,14 @@ Use `"block"` or a CIDR allow list when running untrusted or generated code to p Snapshots let you pre-build an environment image so each run starts with dependencies already installed rather than installing them in setup commands every time. ```toml title="run.toml" -[run.sandbox.daytona.snapshot] -name = "my-snapshot" +[environments.cloud.image] +ref = "my-snapshot" +dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git" + +[environments.cloud.resources] cpu = 4 memory = 8 disk = 20 -dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git" ``` 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. @@ -116,7 +127,10 @@ Fabro automatically clones the run's GitHub origin into the sandbox at `/home/da Set `[run.clone] enabled = false` when a workflow should start with an empty Daytona workspace instead of cloning the run origin: ```toml title="run.toml" -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" [run.clone] @@ -153,14 +167,19 @@ Each sandbox gets a unique timestamped name (e.g. `fabro-20260307-143022-a3f2`) To keep a sandbox alive for debugging: ```bash -fabro run workflow.fabro --sandbox daytona --preserve-sandbox +fabro run workflow.fabro --environment cloud --preserve-sandbox ``` Or in the run config: ```toml title="run.toml" -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" + +[environments.cloud.lifecycle] preserve = true ``` @@ -168,11 +187,11 @@ When preserved, Fabro prints the sandbox name so you can find it in the [Daytona ### Auto-stop -The `auto_stop_interval` setting tells Daytona to stop the sandbox after a period of inactivity, saving costs for preserved or long-running sandboxes: +The `lifecycle.auto_stop` setting tells Daytona to stop the sandbox after a period of inactivity, saving costs for preserved or long-running sandboxes: ```toml title="run.toml" -[run.sandbox.daytona] -auto_stop_interval = 30 +[environments.cloud.lifecycle] +auto_stop = "30m" ``` ## Server defaults @@ -191,7 +210,7 @@ If doctor reports missing scopes, regenerate the Daytona key with `write:snapsho ### "Snapshot does not exist and no dockerfile provided" -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 a `dockerfile` field to `[run.sandbox.daytona.snapshot]`. +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..image]`. ### "Timed out waiting for snapshot to become active" diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index 6535b2e94..58c714a18 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -333,7 +333,7 @@ fabro create [OPTIONS] | `--parent ` | Link this run to an existing orchestration parent run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | | `--provider ` | Override default LLM provider | -| `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | +| `--environment ` | Named environment slug for agent tools | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | @@ -873,7 +873,7 @@ fabro preflight [OPTIONS] | `--goal-file ` | Read the workflow goal from a file | | `--model ` | Override default LLM model | | `--provider ` | Override default LLM provider | -| `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | +| `--environment ` | Named environment slug for agent tools | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | @@ -1037,7 +1037,7 @@ fabro run [OPTIONS] | `--parent ` | Link this run to an existing orchestration parent run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | | `--provider ` | Override default LLM provider | -| `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | +| `--environment ` | Named environment slug for agent tools | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | | `-I, --input ` | Override a workflow input value (repeatable, format: KEY=VALUE) | | `-v, --verbose` | Enable verbose output | @@ -1229,7 +1229,7 @@ fabro server restart [OPTIONS] | `--model ` | Override default LLM model | | `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | | `--provider ` | Override default LLM provider | -| `--sandbox ` | Sandbox for agent tools | +| `--environment ` | Named environment slug for agent tools | | `--storage-dir ` | Local storage directory (default: ~/.fabro/storage) | | `--timeout ` | Seconds to wait for graceful shutdown before SIGKILL
Default: `10` | | `--watch-web` | Run `bun run dev` in apps/fabro-web to watch/recompile web assets (debug only) | @@ -1254,7 +1254,7 @@ fabro server start [OPTIONS] | `--model ` | Override default LLM model | | `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | | `--provider ` | Override default LLM provider | -| `--sandbox ` | Sandbox for agent tools | +| `--environment ` | Named environment slug for agent tools | | `--storage-dir ` | Local storage directory (default: ~/.fabro/storage) | | `--watch-web` | Run `bun run dev` in apps/fabro-web to watch/recompile web assets (debug only) | | `--web` | Enable the embedded web UI and browser auth routes | diff --git a/docs/public/reference/server-operations.mdx b/docs/public/reference/server-operations.mdx index 7e2b35235..2cd678853 100644 --- a/docs/public/reference/server-operations.mdx +++ b/docs/public/reference/server-operations.mdx @@ -48,7 +48,7 @@ Common flags: |---|---|---| | `--bind` | `~/.fabro/fabro.sock` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket | | `--model` | — | Override default LLM model | -| `--sandbox` | — | Override default sandbox provider | +| `--environment` | — | Override default environment slug | | `--max-concurrent-runs` | `5` | Maximum concurrent run executions | See [Server Configuration](/administration/server-configuration) for the full `settings.toml` reference. diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index 549a8a425..bdad7e3c5 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -34,7 +34,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver | Scope | Examples | |---|---| | CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` | -| Shared run defaults | `[run.model]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` | +| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` | | Shared LLM catalog | `[llm.providers.]`, `[llm.models.]`, model limits, features, controls, and costs | | Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 5a42e6c78..9b4f065fe 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -218,33 +218,6 @@ pub(crate) struct InputOverrideArgs { pub(crate) values: Vec, } -#[derive(Debug, Clone, Copy, ValueEnum)] -pub(crate) enum CliSandboxProvider { - Local, - Docker, - Daytona, -} - -impl From for fabro_sandbox::SandboxProvider { - fn from(value: CliSandboxProvider) -> Self { - match value { - CliSandboxProvider::Local => Self::Local, - CliSandboxProvider::Docker => Self::Docker, - CliSandboxProvider::Daytona => Self::Daytona, - } - } -} - -impl From for CliSandboxProvider { - fn from(value: fabro_sandbox::SandboxProvider) -> Self { - match value { - fabro_sandbox::SandboxProvider::Local => Self::Local, - fabro_sandbox::SandboxProvider::Docker => Self::Docker, - fabro_sandbox::SandboxProvider::Daytona => Self::Daytona, - } - } -} - #[derive(Args)] pub(crate) struct RunArgs { #[command(flatten)] @@ -285,9 +258,9 @@ pub(crate) struct RunArgs { #[arg(short, long)] pub(crate) verbose: bool, - /// Sandbox for agent tools - #[arg(long, value_enum)] - pub(crate) sandbox: Option, + /// Named environment for agent tools + #[arg(long)] + pub(crate) environment: Option, /// Attach a label to this run (repeatable, format: KEY=VALUE) #[arg(long = "label", value_name = "KEY=VALUE")] @@ -341,9 +314,9 @@ pub(crate) struct PreflightArgs { #[arg(short, long)] pub(crate) verbose: bool, - /// Sandbox for agent tools - #[arg(long, value_enum)] - pub(crate) sandbox: Option, + /// Named environment for agent tools + #[arg(long)] + pub(crate) environment: Option, } #[derive(Args)] diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index 1fe8e51a5..9e1df3a04 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -121,7 +121,7 @@ draft = true let toml_path = workflow_dir.join("workflow.toml"); std::fs::write( &toml_path, - "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run.sandbox]\nprovider = \"local\"\n", + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run.environment]\nid = \"local\"\n\n[environments.local]\nprovider = \"local\"\n", ) .with_context(|| format!("failed to write {}", toml_path.display()))?; created.push(".fabro/workflows/hello/workflow.toml".to_string()); diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 255bb7009..693e59a7c 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -6,7 +6,6 @@ use fabro_config::{ CliLayer, CliOutputLayer, RunGoalLayer, RunLayer, parse_input_overrides, parse_labels, }; use fabro_manifest::{RunOverrideInput, build_run_overrides}; -use fabro_sandbox::SandboxProvider; use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; @@ -71,13 +70,11 @@ fn current_dir_or_dot() -> PathBuf { pub(crate) fn run_args_overrides(args: &RunArgs) -> Result { let cwd = current_dir_or_dot(); let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?; - let sandbox = args.sandbox.map(SandboxProvider::from); - let sandbox_provider = sandbox.as_ref().map(ToString::to_string); let mut run = build_run_overrides(RunOverrideInput { goal: None, model: args.model.as_deref(), provider: args.provider.as_deref(), - sandbox: sandbox_provider.as_deref(), + environment: args.environment.as_deref(), docker_image: None, preserve_sandbox: sparse_flag(args.preserve_sandbox), dry_run: sparse_flag(args.dry_run), @@ -96,14 +93,11 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result Result { let cwd = current_dir_or_dot(); let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?; - let sandbox_provider = args - .sandbox - .map(|sandbox| SandboxProvider::from(sandbox).to_string()); let mut run = build_run_overrides(RunOverrideInput { goal: None, model: args.model.as_deref(), provider: args.provider.as_deref(), - sandbox: sandbox_provider.as_deref(), + environment: args.environment.as_deref(), docker_image: None, preserve_sandbox: None, dry_run: None, diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index 38e6e7437..e130eba70 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -708,7 +708,7 @@ fn requires_github_credentials(run: &RunNamespace) -> bool { return true; } run.execution.mode != RunMode::DryRun - && clone_sandbox_requires_github_credentials(&run.sandbox.provider) + && clone_sandbox_requires_github_credentials(&run.environment.provider.to_string()) } fn clone_sandbox_requires_github_credentials(provider: &str) -> bool { @@ -1147,8 +1147,8 @@ mod tests { use fabro_types::settings::InterpString; use fabro_types::settings::run::{ - RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode, RunNamespace, - RunSandboxSettings, + EnvironmentProvider, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode, + RunNamespace, }; use super::super::requires_github_credentials; @@ -1160,10 +1160,9 @@ mod tests { ) -> RunNamespace { let mut run = RunNamespace::default(); run.execution.mode = mode; - run.sandbox = RunSandboxSettings { - provider: provider.to_string(), - ..RunSandboxSettings::default() - }; + run.environment.provider = provider + .parse::() + .expect("test provider should parse"); run.integrations = RunIntegrationsSettings { github: RunIntegrationsGithubSettings { permissions }, }; diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 6d42ed1cf..d2e6a9c83 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -147,7 +147,7 @@ async fn ensure_server_running_with_bind( no_web: false, model: None, provider: None, - sandbox: None, + environment: None, max_concurrent_runs: server_max_concurrent_runs_override(), config: Some(config_path.to_path_buf()), #[cfg(debug_assertions)] @@ -322,8 +322,8 @@ async fn execute_daemon( if serve_args.no_web { cmd.arg("--no-web"); } - if let Some(ref sandbox) = serve_args.sandbox { - cmd.args(["--sandbox", &sandbox.to_string()]); + if let Some(ref environment) = serve_args.environment { + cmd.args(["--environment", environment]); } if let Some(max) = serve_args.max_concurrent_runs { cmd.args(["--max-concurrent-runs", &max.to_string()]); @@ -589,7 +589,7 @@ destination = "{destination}" no_web: false, model: None, provider: None, - sandbox: None, + environment: None, max_concurrent_runs: None, config: Some(config_path.to_path_buf()), #[cfg(debug_assertions)] diff --git a/lib/crates/fabro-cli/src/manifest_args.rs b/lib/crates/fabro-cli/src/manifest_args.rs index 48fc1dbab..f3ff40417 100644 --- a/lib/crates/fabro-cli/src/manifest_args.rs +++ b/lib/crates/fabro-cli/src/manifest_args.rs @@ -10,9 +10,7 @@ pub(crate) fn run_manifest_args(args: &RunArgs) -> Option { model: args.model.clone(), preserve_sandbox: args.preserve_sandbox.then_some(true), provider: args.provider.clone(), - sandbox: args - .sandbox - .map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()), + environment: args.environment.clone(), docker_image: None, input: args.inputs.values.clone(), verbose: args.verbose.then_some(true), @@ -28,9 +26,7 @@ pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option Result { let layer = layer.clone().combine(DEFAULTS_LAYER.clone()); let mut errors = Vec::new(); - let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); + let environments = resolve_environments(&layer.environments, &mut errors); + let run = resolve_run( + &layer.run.clone().unwrap_or_default(), + &environments, + &mut errors, + ); finish_result(run, "failed to resolve run settings", errors) } @@ -204,10 +210,11 @@ impl RunSettingsBuilder { #[derive(Clone)] pub struct ServerRuntimeSettings { - pub server_settings: ServerSettings, - pub manifest_run_defaults: RunLayer, - pub manifest_run_settings: std::result::Result, - pub llm_catalog_settings: model_catalog::LlmCatalogSettings, + pub server_settings: ServerSettings, + pub manifest_run_defaults: RunLayer, + pub manifest_environment_defaults: crate::MergeMap, + pub manifest_run_settings: std::result::Result, + pub llm_catalog_settings: model_catalog::LlmCatalogSettings, } pub fn load_server_runtime_settings( @@ -263,12 +270,18 @@ fn resolve_server_runtime_settings( } let manifest_run_defaults = layer.run.clone().unwrap_or_default(); + let manifest_environment_defaults = layer.environments.clone(); let llm_catalog_settings = llm_catalog_settings_from_layer(&layer); Ok(ServerRuntimeSettings { server_settings: ServerSettingsBuilder::from_layer(&layer)?, - manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults) - .map_err(|err| SharedError::new(anyhow::Error::new(err))), + manifest_run_settings: RunSettingsBuilder::from_layer(&SettingsLayer { + run: Some(manifest_run_defaults.clone()), + environments: manifest_environment_defaults.clone(), + ..SettingsLayer::default() + }) + .map_err(|err| SharedError::new(anyhow::Error::new(err))), manifest_run_defaults, + manifest_environment_defaults, llm_catalog_settings, }) } @@ -429,7 +442,7 @@ impl WorkflowSettingsBuilder { } #[must_use] - pub(crate) fn workflow_layer(mut self, layer: SettingsLayer) -> Self { + pub fn workflow_layer(mut self, layer: SettingsLayer) -> Self { self.workflow = layer; self } @@ -462,7 +475,7 @@ impl WorkflowSettingsBuilder { } #[must_use] - pub(crate) fn project_layer(mut self, layer: SettingsLayer) -> Self { + pub fn project_layer(mut self, layer: SettingsLayer) -> Self { self.project = layer; self } @@ -517,6 +530,19 @@ impl WorkflowSettingsBuilder { }) } + #[must_use] + pub fn server_manifest_defaults( + self, + run: RunLayer, + environments: MergeMap, + ) -> Self { + self.server_layer(SettingsLayer { + run: Some(run), + environments, + ..SettingsLayer::default() + }) + } + #[must_use] pub fn run_overrides(self, run: RunLayer) -> Self { self.args_layer(SettingsLayer { @@ -538,6 +564,7 @@ impl WorkflowSettingsBuilder { let server_defaults = SettingsLayer { version: self.server.version, run: self.server.run, + environments: self.server.environments, ..SettingsLayer::default() }; let mut layer = self @@ -563,11 +590,17 @@ impl WorkflowSettingsBuilder { let mut errors = Vec::new(); let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors); let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors); - let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); + let environments = resolve_environments(&layer.environments, &mut errors); + let run = resolve_run( + &layer.run.clone().unwrap_or_default(), + &environments, + &mut errors, + ); finish_dense_result( WorkflowSettings { project, workflow, + environments, run, }, errors, diff --git a/lib/crates/fabro-config/src/defaults.toml b/lib/crates/fabro-config/src/defaults.toml index 529a5f299..4c78d1609 100644 --- a/lib/crates/fabro-config/src/defaults.toml +++ b/lib/crates/fabro-config/src/defaults.toml @@ -23,16 +23,42 @@ push = true enabled = true push = true -[run.sandbox] +[run.environment] +id = "default" + +[environments.default] provider = "docker" + +[environments.default.image] +ref = "buildpack-deps:noble" + +[environments.default.resources] +cpu = 2 +memory = "4GB" + +[environments.default.lifecycle] preserve = false stop_on_terminal = true -devcontainer = false -[run.sandbox.docker] -image = "buildpack-deps:noble" -memory_limit = "4GB" -cpu_quota = 200000 +[environments.local] +provider = "local" + +[environments.docker] +provider = "docker" + +[environments.docker.image] +ref = "buildpack-deps:noble" + +[environments.docker.resources] +cpu = 2 +memory = "4GB" + +[environments.docker.lifecycle] +preserve = false +stop_on_terminal = true + +[environments.daytona] +provider = "daytona" [cli.output] format = "text" diff --git a/lib/crates/fabro-config/src/layers/combine.rs b/lib/crates/fabro-config/src/layers/combine.rs index 6f919e179..a99f352e2 100644 --- a/lib/crates/fabro-config/src/layers/combine.rs +++ b/lib/crates/fabro-config/src/layers/combine.rs @@ -3,7 +3,8 @@ use std::collections::{BTreeMap, HashMap}; use fabro_model::{AgentProfileKind, BillingPolicy, ProviderAuthConfig}; use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity}; use fabro_types::settings::run::{ - AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode, + AgentPermissions, ApprovalMode, EnvironmentNetworkMode, EnvironmentProvider, MergeStrategy, + RunMode, }; use fabro_types::settings::server::{ GithubIntegrationStrategy, LogDestination, ObjectStoreProvider, ServerAuthMethod, @@ -13,11 +14,12 @@ use fabro_types::settings::{Duration, InterpString, Size}; use super::LogFilter; use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer}; +use super::environment::{EnvironmentDockerfileLayer, EnvironmentVolumeLayer}; use super::llm::{CostRates, CredentialRef, HeaderValueRef, ReasoningEffortFeature}; use super::run::{ - DaytonaSnapshotLayer, DaytonaVolumeLayer, HookAgentMarker, HookEntry, HookTlsMode, - InterviewProviderLayer, ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, - RunCheckpointLayer, RunGoalLayer, RunPrepareLayer, ScmGitHubLayer, StringOrSplice, + HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, ModelRefOrSplice, + NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, RunGoalLayer, + RunPrepareLayer, ScmGitHubLayer, StringOrSplice, }; use super::server::{ ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer, @@ -43,7 +45,7 @@ impl Combine for Option { } } -impl Combine for Option> { +impl Combine for Option> { fn combine(self, other: Self) -> Self { self.or(other) } @@ -147,8 +149,9 @@ impl_combine_self!( CliAuthLayer, CliLoggingLayer, CliTargetLayer, - DaytonaNetworkLayer, - DaytonaSnapshotLayer, + EnvironmentNetworkMode, + EnvironmentProvider, + EnvironmentDockerfileLayer, InterviewProviderLayer, NotificationProviderLayer, RunArtifactsLayer, diff --git a/lib/crates/fabro-config/src/layers/environment.rs b/lib/crates/fabro-config/src/layers/environment.rs new file mode 100644 index 000000000..e72103fb0 --- /dev/null +++ b/lib/crates/fabro-config/src/layers/environment.rs @@ -0,0 +1,134 @@ +//! Sparse top-level `[environments.]` settings layer definitions. + +use fabro_types::settings::{Duration, InterpString, Size}; +use serde::{Deserialize, Serialize}; + +use super::combine::Combine; +use super::maps::StickyMap; + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lifecycle: Option, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub labels: StickyMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub volumes: Option>, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub env: StickyMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct RunEnvironmentLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lifecycle: Option, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub labels: StickyMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub volumes: Option>, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub env: StickyMap, +} + +impl RunEnvironmentLayer { + #[must_use] + pub fn into_environment_override(self) -> EnvironmentLayer { + EnvironmentLayer { + provider: None, + image: self.image, + resources: self.resources, + network: self.network, + lifecycle: self.lifecycle, + labels: self.labels, + volumes: self.volumes, + env: self.env, + } + } +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dockerfile: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentResourcesLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentNetworkLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, +} + +impl Combine for EnvironmentNetworkLayer { + fn combine(self, other: Self) -> Self { + Self { + mode: self.mode.or(other.mode), + allow: if self.allow.is_empty() { + other.allow + } else { + self.allow + }, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentLifecycleLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preserve: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_on_terminal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_stop: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentVolumeLayer { + pub id: String, + pub mount_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subpath: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum EnvironmentDockerfileLayer { + Inline(String), + Path { path: String }, +} diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs index fc67327b2..bf5f0a38b 100644 --- a/lib/crates/fabro-config/src/layers/mod.rs +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -1,5 +1,6 @@ mod cli; mod combine; +mod environment; mod llm; mod log_filter; mod maps; @@ -15,6 +16,11 @@ pub use cli::{ CliOutputLayer, CliTargetLayer, CliUpdatesLayer, }; pub(crate) use combine::Combine; +pub use environment::{ + EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer, + EnvironmentNetworkLayer, EnvironmentResourcesLayer, EnvironmentVolumeLayer, + RunEnvironmentLayer, +}; pub use llm::{ CostRates, CredentialRef, CredentialRefParseError, HeaderValueRef, LlmLayer, ModelControls, ModelCostTable, ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits, @@ -24,14 +30,13 @@ pub use log_filter::LogFilter; pub use maps::{MergeMap, ReplaceMap, StickyMap}; pub use project::ProjectLayer; pub use run::{ - DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, DaytonaVolumeLayer, - DockerSandboxLayer, GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode, - InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice, - NotificationProviderLayer, NotificationRouteLayer, PrepareStep, RunAgentLayer, - RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer, - RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, - RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, - RunSandboxLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice, + GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, + InterviewsLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer, + NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, + RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, + RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, + RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, + StringOrSplice, }; pub use server::{ GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, @@ -40,5 +45,5 @@ pub use server::{ ServerListenLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, }; -pub(crate) use settings::SettingsLayer; +pub use settings::SettingsLayer; pub use workflow::WorkflowLayer; diff --git a/lib/crates/fabro-config/src/layers/run.rs b/lib/crates/fabro-config/src/layers/run.rs index 63a12130b..d628c3e6d 100644 --- a/lib/crates/fabro-config/src/layers/run.rs +++ b/lib/crates/fabro-config/src/layers/run.rs @@ -3,12 +3,13 @@ use std::collections::HashMap; use fabro_types::settings::run::{ - AgentPermissions, ApprovalMode, DaytonaNetworkLayer, HookEvent, MergeStrategy, RunMode, + AgentPermissions, ApprovalMode, HookEvent, MergeStrategy, RunMode, }; -use fabro_types::settings::{Duration, InterpString, ModelRef, Size}; +use fabro_types::settings::{Duration, InterpString, ModelRef}; use serde::{Deserialize, Serialize}; use super::combine::Combine; +use super::environment::RunEnvironmentLayer; use super::maps::{MergeMap, ReplaceMap, StickyMap}; use super::splice_array::SPLICE_MARKER; @@ -42,7 +43,7 @@ pub struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub meta_branch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, + pub environment: Option, #[serde(default, skip_serializing_if = "MergeMap::is_empty")] pub notifications: MergeMap, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -314,90 +315,6 @@ pub struct RunMetaBranchLayer { pub push: Option, } -/// `[run.sandbox]` — sandbox selection and execution-environment surface. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct RunSandboxLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preserve: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stop_on_terminal: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub devcontainer: Option, - /// Sticky merge-by-key across layers. - #[serde(default, skip_serializing_if = "StickyMap::is_empty")] - pub env: StickyMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub docker: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub daytona: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct DockerSandboxLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub network_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory_limit: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cpu_quota: Option, - #[serde(default, skip_serializing_if = "StickyMap::is_empty")] - pub env_vars: StickyMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -#[serde(deny_unknown_fields)] -pub struct DaytonaSandboxLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auto_stop_interval: Option, - /// Sticky merge-by-key (provider-native labels). - #[serde(default, skip_serializing_if = "StickyMap::is_empty")] - pub labels: StickyMap, - /// Existing Daytona volumes to mount when creating the sandbox. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub volumes: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub snapshot: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub network: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DaytonaVolumeLayer { - pub volume_id: String, - pub mount_path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subpath: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DaytonaSnapshotLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cpu: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disk: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dockerfile: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged, deny_unknown_fields)] -pub enum DaytonaDockerfileLayer { - Inline(String), - Path { path: String }, -} - /// `[run.notifications.]` — a keyed notification route. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] diff --git a/lib/crates/fabro-config/src/layers/settings.rs b/lib/crates/fabro-config/src/layers/settings.rs index 653b4f0cd..8c5c362fb 100644 --- a/lib/crates/fabro-config/src/layers/settings.rs +++ b/lib/crates/fabro-config/src/layers/settings.rs @@ -10,7 +10,9 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; use super::cli::CliLayer; +use super::environment::EnvironmentLayer; use super::llm::LlmLayer; +use super::maps::MergeMap; use super::project::ProjectLayer; use super::run::RunLayer; use super::server::ServerLayer; @@ -19,21 +21,23 @@ use crate::parse::{ParseError, parse_settings}; /// A sparse settings layer before merge/resolve. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] -pub(crate) struct SettingsLayer { +pub struct SettingsLayer { #[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")] - pub version: Option, + pub version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub project: Option, + pub project: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow: Option, + pub workflow: Option, + #[serde(default, skip_serializing_if = "MergeMap::is_empty")] + pub environments: MergeMap, #[serde(default, skip_serializing_if = "Option::is_none")] - pub run: Option, + pub run: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub cli: Option, + pub cli: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub server: Option, + pub server: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub llm: Option, + pub llm: Option, } impl FromStr for SettingsLayer { diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index e3d3df83c..e2b6fb3b8 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -38,26 +38,27 @@ pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; pub use home::Home; pub use input_overrides::{InputOverrideParseError, parse_input_overrides, parse_labels}; +pub(crate) use layers::Combine; pub use layers::{ CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer, CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef, - CredentialRefParseError, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, - DaytonaVolumeLayer, DockerSandboxLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef, + CredentialRefParseError, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, + EnvironmentLifecycleLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, + EnvironmentVolumeLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings, NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer, - RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer, - RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, - RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, - RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, + RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, + RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, + RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, + RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, - ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, + ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, }; -pub(crate) use layers::{Combine, SettingsLayer}; pub use logging::{resolve_log_destination, resolve_log_destination_with_env}; pub use parse::ParseError; pub use resolve::{ diff --git a/lib/crates/fabro-config/src/parse.rs b/lib/crates/fabro-config/src/parse.rs index fa0387cfb..66ea23680 100644 --- a/lib/crates/fabro-config/src/parse.rs +++ b/lib/crates/fabro-config/src/parse.rs @@ -5,7 +5,14 @@ use crate::SettingsLayer; const CURRENT_VERSION: u32 = 1; const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[ - "_version", "project", "workflow", "run", "cli", "server", "llm", + "_version", + "project", + "workflow", + "environments", + "run", + "cli", + "server", + "llm", ]; /// Legacy `[llm]` keys that pre-date the settings-driven catalog plan and @@ -38,7 +45,7 @@ impl fmt::Display for ParseError { } else { write!( f, - "unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `llm`" + "unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `environments`, `run`, `cli`, `server`, `llm`" ) } } @@ -128,7 +135,7 @@ fn rename_hint(key: &str) -> Option { "llm" => "rename to `[run.model]`", "vars" => "rename to `[run.inputs]`", "setup" => "rename to `[run.prepare]`", - "sandbox" => "move under `[run.sandbox]`", + "sandbox" => "rename to `[run.environment]` and `[environments.]`", "checkpoint" => "move under `[run.checkpoint]`", "pull_request" => "move under `[run.pull_request]`", "artifacts" => "move under `[run.artifacts]`", diff --git a/lib/crates/fabro-config/src/resolve/environment.rs b/lib/crates/fabro-config/src/resolve/environment.rs new file mode 100644 index 000000000..3ec87cccc --- /dev/null +++ b/lib/crates/fabro-config/src/resolve/environment.rs @@ -0,0 +1,335 @@ +use std::collections::HashMap; + +use fabro_types::settings::run::{ + DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings, + EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider, + EnvironmentResourcesSettings, EnvironmentSettings, EnvironmentVolumeSettings, + RunEnvironmentSettings, +}; + +use super::ResolveError; +use crate::{ + EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer, + EnvironmentNetworkLayer, EnvironmentResourcesLayer, EnvironmentVolumeLayer, MergeMap, + RunEnvironmentLayer, +}; + +pub(crate) fn resolve_environments( + layers: &MergeMap, + errors: &mut Vec, +) -> HashMap { + layers + .iter() + .map(|(slug, layer)| { + let path = format!("environments.{slug}"); + ( + slug.clone(), + resolve_environment_layer(layer, &path, errors), + ) + }) + .collect() +} + +pub(crate) fn resolve_run_environment( + layer: Option<&RunEnvironmentLayer>, + environments: &HashMap, + errors: &mut Vec, +) -> RunEnvironmentSettings { + let layer = layer.expect("defaults.toml should provide run.environment defaults"); + let id = layer.id.clone().unwrap_or_else(|| { + errors.push(ResolveError::Missing { + path: "run.environment.id".to_string(), + }); + "default".to_string() + }); + + let Some(base) = environments.get(&id) else { + errors.push(ResolveError::Invalid { + path: "run.environment.id".to_string(), + reason: format!("unknown environment: {id}"), + }); + return RunEnvironmentSettings::from_environment(id, EnvironmentSettings::default()); + }; + + let mut environment = base.clone(); + apply_run_environment_overrides(&mut environment, layer, errors); + validate_provider_capabilities(&environment, "run.environment", errors); + RunEnvironmentSettings::from_environment(id, environment) +} + +fn resolve_environment_layer( + layer: &EnvironmentLayer, + path: &str, + errors: &mut Vec, +) -> EnvironmentSettings { + let provider = if let Some(raw) = layer.provider.as_deref() { + parse_provider(raw, &format!("{path}.provider"), errors) + } else { + errors.push(ResolveError::Missing { + path: format!("{path}.provider"), + }); + EnvironmentProvider::Local + }; + + let environment = EnvironmentSettings { + provider, + image: resolve_image(layer.image.as_ref()), + resources: resolve_resources(layer.resources.as_ref()), + network: resolve_network(layer.network.as_ref(), &format!("{path}.network"), errors), + lifecycle: resolve_lifecycle(layer.lifecycle.as_ref()), + labels: layer.labels.clone().into_inner(), + volumes: resolve_volumes(layer.volumes.as_deref()), + env: layer.env.clone().into_inner(), + }; + validate_daytona_snapshot_name(&environment, path, errors); + environment +} + +fn parse_provider(raw: &str, path: &str, errors: &mut Vec) -> EnvironmentProvider { + if let Ok(provider) = raw.parse::() { + provider + } else { + errors.push(ResolveError::Invalid { + path: path.to_string(), + reason: format!("unknown environment provider: {raw}"), + }); + EnvironmentProvider::Local + } +} + +fn resolve_image(layer: Option<&EnvironmentImageLayer>) -> EnvironmentImageSettings { + let Some(layer) = layer else { + return EnvironmentImageSettings::default(); + }; + EnvironmentImageSettings { + reference: layer.reference.clone(), + dockerfile: layer.dockerfile.as_ref().map(dockerfile_source), + } +} + +fn resolve_resources(layer: Option<&EnvironmentResourcesLayer>) -> EnvironmentResourcesSettings { + let Some(layer) = layer else { + return EnvironmentResourcesSettings::default(); + }; + EnvironmentResourcesSettings { + cpu: layer.cpu, + memory: layer.memory, + disk: layer.disk, + } +} + +fn resolve_network( + layer: Option<&EnvironmentNetworkLayer>, + path: &str, + errors: &mut Vec, +) -> EnvironmentNetworkSettings { + let Some(layer) = layer else { + return EnvironmentNetworkSettings::default(); + }; + + for (index, cidr) in layer.allow.iter().enumerate() { + if cidr.parse::().is_err() { + errors.push(ResolveError::Invalid { + path: format!("{path}.allow[{index}]"), + reason: format!("invalid CIDR: {cidr}"), + }); + } + } + + let mode = match layer.mode.as_deref() { + Some(raw) => parse_network_mode(raw, &format!("{path}.mode"), errors), + None if layer.allow.is_empty() => EnvironmentNetworkMode::AllowAll, + None => EnvironmentNetworkMode::CidrAllowList, + }; + + EnvironmentNetworkSettings { + mode, + allow: layer.allow.clone(), + } +} + +fn parse_network_mode( + raw: &str, + path: &str, + errors: &mut Vec, +) -> EnvironmentNetworkMode { + if let Ok(mode) = raw.parse::() { + mode + } else { + errors.push(ResolveError::Invalid { + path: path.to_string(), + reason: format!("unknown environment network mode: {raw}"), + }); + EnvironmentNetworkMode::AllowAll + } +} + +fn resolve_lifecycle(layer: Option<&EnvironmentLifecycleLayer>) -> EnvironmentLifecycleSettings { + let Some(layer) = layer else { + return EnvironmentLifecycleSettings::default(); + }; + EnvironmentLifecycleSettings { + preserve: layer.preserve.unwrap_or(false), + stop_on_terminal: layer.stop_on_terminal.unwrap_or(true), + auto_stop: layer.auto_stop, + } +} + +fn resolve_volumes(layers: Option<&[EnvironmentVolumeLayer]>) -> Vec { + layers + .unwrap_or(&[]) + .iter() + .map(|volume| EnvironmentVolumeSettings { + id: volume.id.clone(), + mount_path: volume.mount_path.clone(), + subpath: volume.subpath.clone(), + }) + .collect() +} + +fn apply_run_environment_overrides( + environment: &mut EnvironmentSettings, + layer: &RunEnvironmentLayer, + errors: &mut Vec, +) { + if let Some(image) = layer.image.as_ref() { + apply_image_override(&mut environment.image, image); + } + if let Some(resources) = layer.resources.as_ref() { + apply_resources_override(&mut environment.resources, resources); + } + if let Some(network) = layer.network.as_ref() { + apply_network_override( + &mut environment.network, + network, + "run.environment.network", + errors, + ); + } + if let Some(lifecycle) = layer.lifecycle.as_ref() { + apply_lifecycle_override(&mut environment.lifecycle, lifecycle); + } + environment.labels.extend(layer.labels.clone().into_inner()); + if let Some(volumes) = layer.volumes.as_deref() { + environment.volumes = resolve_volumes(Some(volumes)); + } + environment.env.extend(layer.env.clone().into_inner()); +} + +fn apply_image_override(target: &mut EnvironmentImageSettings, layer: &EnvironmentImageLayer) { + if let Some(reference) = layer.reference.as_ref() { + target.reference = Some(reference.clone()); + } + if let Some(dockerfile) = layer.dockerfile.as_ref() { + target.dockerfile = Some(dockerfile_source(dockerfile)); + } +} + +fn apply_resources_override( + target: &mut EnvironmentResourcesSettings, + layer: &EnvironmentResourcesLayer, +) { + if layer.cpu.is_some() { + target.cpu = layer.cpu; + } + if layer.memory.is_some() { + target.memory = layer.memory; + } + if layer.disk.is_some() { + target.disk = layer.disk; + } +} + +fn apply_network_override( + target: &mut EnvironmentNetworkSettings, + layer: &EnvironmentNetworkLayer, + path: &str, + errors: &mut Vec, +) { + for (index, cidr) in layer.allow.iter().enumerate() { + if cidr.parse::().is_err() { + errors.push(ResolveError::Invalid { + path: format!("{path}.allow[{index}]"), + reason: format!("invalid CIDR: {cidr}"), + }); + } + } + if let Some(raw) = layer.mode.as_deref() { + target.mode = parse_network_mode(raw, &format!("{path}.mode"), errors); + } + if !layer.allow.is_empty() { + target.allow.clone_from(&layer.allow); + } +} + +fn dockerfile_source(dockerfile: &EnvironmentDockerfileLayer) -> DockerfileSource { + match dockerfile { + EnvironmentDockerfileLayer::Inline(text) => DockerfileSource::Inline(text.clone()), + EnvironmentDockerfileLayer::Path { path } => DockerfileSource::Path { path: path.clone() }, + } +} + +fn apply_lifecycle_override( + target: &mut EnvironmentLifecycleSettings, + layer: &EnvironmentLifecycleLayer, +) { + if let Some(preserve) = layer.preserve { + target.preserve = preserve; + } + if let Some(stop_on_terminal) = layer.stop_on_terminal { + target.stop_on_terminal = stop_on_terminal; + } + if layer.auto_stop.is_some() { + target.auto_stop = layer.auto_stop; + } +} + +fn validate_daytona_snapshot_name( + environment: &EnvironmentSettings, + path: &str, + errors: &mut Vec, +) { + if environment.provider == EnvironmentProvider::Daytona + && environment.image.dockerfile.is_some() + && environment.image.reference.is_none() + { + errors.push(ResolveError::Invalid { + path: format!("{path}.image"), + reason: "daytona environments with image.dockerfile must also set image.ref" + .to_string(), + }); + } +} + +fn validate_provider_capabilities( + environment: &EnvironmentSettings, + path: &str, + errors: &mut Vec, +) { + validate_daytona_snapshot_name(environment, path, errors); + match environment.provider { + EnvironmentProvider::Local => { + if matches!( + environment.network.mode, + EnvironmentNetworkMode::Block | EnvironmentNetworkMode::CidrAllowList + ) { + errors.push(ResolveError::Invalid { + path: format!("{path}.network.mode"), + reason: + "local environments cannot enforce blocked or CIDR allow-list networking" + .to_string(), + }); + } + } + EnvironmentProvider::Docker => { + if environment.network.mode == EnvironmentNetworkMode::CidrAllowList { + errors.push(ResolveError::Invalid { + path: format!("{path}.network.mode"), + reason: "docker environments cannot enforce CIDR allow-list networking" + .to_string(), + }); + } + } + EnvironmentProvider::Daytona => {} + } +} diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 966314ee5..5206d31c6 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -1,4 +1,5 @@ mod cli; +mod environment; mod error; mod project; mod run; @@ -6,6 +7,7 @@ mod server; mod workflow; pub use cli::resolve_cli; +pub(crate) use environment::{resolve_environments, resolve_run_environment}; pub use error::ResolveError; use fabro_types::settings::InterpString; pub use project::resolve_project; diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index f8d3fc2d1..ee4eac7ba 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -1,26 +1,28 @@ use fabro_types::settings::InterpString; use fabro_types::settings::run::{ - ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DaytonaVolumeSettings, - DockerSettings, DockerfileSource, GitAuthorSettings, HookDefinition, HookType, + ArtifactsSettings, EnvironmentSettings, GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings, RunAgentSettings, RunBranchSettings, RunCheckpointSettings, RunCloneSettings, RunExecutionSettings, RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunInterviewsSettings, RunMetaBranchSettings, RunModelControls, RunModelSettings, RunNamespace, - RunPrepareSettings, RunSandboxSettings, RunScmSettings, ScmGitHubSettings, TlsMode, + RunPrepareSettings, RunScmSettings, ScmGitHubSettings, TlsMode, }; -use super::ResolveError; +use super::{ResolveError, resolve_run_environment}; use crate::{ - DaytonaDockerfileLayer, DaytonaSandboxLayer, HookAgentMarker, HookEntry, HookTlsMode, - InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice, - NotificationProviderLayer, NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer, - RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, - RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelLayer, RunPrepareLayer, - RunPullRequestLayer, RunRunBranchLayer, RunSandboxLayer, RunScmLayer, StringOrSplice, + HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, InterviewsLayer, + McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, + RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, + RunGitLayer, RunGoalLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelLayer, + RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, StringOrSplice, }; -pub fn resolve_run(layer: &RunLayer, errors: &mut Vec) -> RunNamespace { +pub fn resolve_run( + layer: &RunLayer, + environments: &std::collections::HashMap, + errors: &mut Vec, +) -> RunNamespace { let clone = resolve_clone(layer.clone.as_ref()); let run_branch = resolve_run_branch(layer.run_branch.as_ref()); let mut meta_branch = resolve_meta_branch(layer.meta_branch.as_ref()); @@ -59,7 +61,7 @@ pub fn resolve_run(layer: &RunLayer, errors: &mut Vec) -> RunNames clone, run_branch, meta_branch, - sandbox: resolve_sandbox(layer.sandbox.as_ref(), errors), + environment: resolve_run_environment(layer.environment.as_ref(), environments, errors), notifications: layer .notifications .iter() @@ -221,91 +223,6 @@ fn resolve_meta_branch(meta_branch: Option<&RunMetaBranchLayer>) -> RunMetaBranc } } -fn resolve_sandbox( - sandbox: Option<&RunSandboxLayer>, - errors: &mut Vec, -) -> RunSandboxSettings { - let sandbox = sandbox.expect("defaults.toml should provide run.sandbox defaults"); - - let provider = sandbox - .provider - .clone() - .expect("defaults.toml should provide run.sandbox.provider"); - match provider.as_str() { - "local" | "docker" | "daytona" => {} - other => errors.push(ResolveError::Invalid { - path: "run.sandbox.provider".to_string(), - reason: format!("unknown sandbox provider: {other}"), - }), - } - - RunSandboxSettings { - provider, - preserve: sandbox - .preserve - .expect("defaults.toml should provide run.sandbox.preserve"), - stop_on_terminal: sandbox - .stop_on_terminal - .expect("defaults.toml should provide run.sandbox.stop_on_terminal"), - devcontainer: sandbox - .devcontainer - .expect("defaults.toml should provide run.sandbox.devcontainer"), - env: sandbox.env.clone().into_inner(), - docker: sandbox.docker.as_ref().map(resolve_docker), - daytona: sandbox.daytona.as_ref().map(resolve_daytona), - } -} - -fn resolve_docker(docker: &crate::DockerSandboxLayer) -> DockerSettings { - DockerSettings { - image: docker.image.clone().unwrap_or_default(), - network_mode: docker.network_mode.clone(), - memory_limit: docker - .memory_limit - .and_then(|size| i64::try_from(size.as_bytes()).ok()), - cpu_quota: docker.cpu_quota, - env_vars: docker.env_vars.clone().into_inner(), - } -} - -fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings { - DaytonaSettings { - auto_stop_interval: daytona.auto_stop_interval, - labels: daytona.labels.clone().into_inner(), - volumes: daytona - .volumes - .as_deref() - .unwrap_or(&[]) - .iter() - .map(|volume| DaytonaVolumeSettings { - volume_id: volume.volume_id.clone(), - mount_path: volume.mount_path.clone(), - subpath: volume.subpath.clone(), - }) - .collect(), - snapshot: daytona.snapshot.as_ref().and_then(|snapshot| { - snapshot.name.as_ref().map(|name| DaytonaSnapshotSettings { - name: name.clone(), - cpu: snapshot.cpu, - memory_gb: snapshot.memory.map(|size| size_to_gb_i32(size.as_bytes())), - disk_gb: snapshot.disk.map(|size| size_to_gb_i32(size.as_bytes())), - dockerfile: snapshot - .dockerfile - .as_ref() - .map(|dockerfile| match dockerfile { - DaytonaDockerfileLayer::Inline(text) => { - DockerfileSource::Inline(text.clone()) - } - DaytonaDockerfileLayer::Path { path } => { - DockerfileSource::Path { path: path.clone() } - } - }), - }) - }), - network: daytona.network.clone(), - } -} - fn resolve_notification_route(route: &NotificationRouteLayer) -> NotificationRouteSettings { NotificationRouteSettings { enabled: route.enabled.unwrap_or(false), @@ -570,8 +487,3 @@ fn resolve_artifacts(artifacts: Option<&RunArtifactsLayer>) -> ArtifactsSettings .unwrap_or_default(), } } - -fn size_to_gb_i32(bytes: u64) -> i32 { - let gb = bytes / 1_000_000_000; - i32::try_from(gb).unwrap_or(i32::MAX) -} diff --git a/lib/crates/fabro-config/src/tests/combine.rs b/lib/crates/fabro-config/src/tests/combine.rs index 51b0a68fe..557c4316c 100644 --- a/lib/crates/fabro-config/src/tests/combine.rs +++ b/lib/crates/fabro-config/src/tests/combine.rs @@ -33,30 +33,38 @@ a = "higher" } #[test] -fn run_sandbox_env_merges_sticky() { +fn run_environment_env_merges_sticky() { let lower = parse( r#" -[run.sandbox.env] +[run.environment.env] A = "lower-a" B = "lower-b" "#, ); let higher = parse( r#" -[run.sandbox.env] +[run.environment.env] A = "higher-a" C = "higher-c" "#, ); let merged = higher.combine(lower); - let sandbox = merged.run.unwrap().sandbox.unwrap(); - assert_eq!(sandbox.env.len(), 3); + let environment = merged.run.unwrap().environment.unwrap(); + assert_eq!(environment.env.len(), 3); assert_eq!( - sandbox.env.get("A").map(InterpString::as_source).as_deref(), + environment + .env + .get("A") + .map(InterpString::as_source) + .as_deref(), Some("higher-a") ); assert_eq!( - sandbox.env.get("B").map(InterpString::as_source).as_deref(), + environment + .env + .get("B") + .map(InterpString::as_source) + .as_deref(), Some("lower-b") ); } diff --git a/lib/crates/fabro-config/src/tests/defaults.rs b/lib/crates/fabro-config/src/tests/defaults.rs index 79b652084..210bf87c1 100644 --- a/lib/crates/fabro-config/src/tests/defaults.rs +++ b/lib/crates/fabro-config/src/tests/defaults.rs @@ -58,8 +58,15 @@ fn apply_builtin_defaults_materializes_expected_layer() { layer .run .as_ref() - .and_then(|run| run.sandbox.as_ref()) - .and_then(|sandbox| sandbox.provider.as_deref()), + .and_then(|run| run.environment.as_ref()) + .and_then(|environment| environment.id.as_deref()), + Some("default") + ); + assert_eq!( + layer + .environments + .get("default") + .and_then(|environment| environment.provider.as_deref()), Some("docker") ); assert_eq!( diff --git a/lib/crates/fabro-config/src/tests/resolve_root.rs b/lib/crates/fabro-config/src/tests/resolve_root.rs index 99fe0fef6..94c261711 100644 --- a/lib/crates/fabro-config/src/tests/resolve_root.rs +++ b/lib/crates/fabro-config/src/tests/resolve_root.rs @@ -35,7 +35,10 @@ methods = ["github"] [server.auth.github] allowed_usernames = [] -[run.sandbox] +[run.environment] +id = "bad" + +[environments.bad] provider = "not-a-provider" "#; @@ -64,7 +67,7 @@ provider = "not-a-provider" assert!(rendered.contains("server.listen.address")); assert!(rendered.contains("server.auth.github.allowed_usernames")); - assert!(rendered.contains("run.sandbox.provider")); + assert!(rendered.contains("environments.bad.provider")); } #[test] @@ -165,12 +168,15 @@ shared = "run" } #[test] -fn workflow_settings_report_invalid_run_sandbox_provider() { +fn workflow_settings_report_invalid_environment_provider() { let errors = match fabro_config::WorkflowSettingsBuilder::from_toml( r#" _version = 1 -[run.sandbox] +[run.environment] +id = "bad" + +[environments.bad] provider = "not-a-provider" "#, ) @@ -183,7 +189,7 @@ provider = "not-a-provider" assert!(errors.iter().any(|error| { matches!( error, - fabro_config::ResolveError::Invalid { path, .. } if path == "run.sandbox.provider" + fabro_config::ResolveError::Invalid { path, .. } if path == "environments.bad.provider" ) })); } @@ -194,7 +200,10 @@ fn workflow_settings_accumulate_multiple_run_errors() { r#" _version = 1 -[run.sandbox] +[run.environment] +id = "bad" + +[environments.bad] provider = "not-a-provider" [[run.prepare.steps]] @@ -205,6 +214,6 @@ command = ["echo", "hi"] .expect_err("invalid workflow settings should fail") .to_string(); - assert!(rendered.contains("run.sandbox.provider")); + assert!(rendered.contains("environments.bad.provider")); assert!(rendered.contains("run.prepare.steps[0]")); } diff --git a/lib/crates/fabro-config/src/tests/resolve_run.rs b/lib/crates/fabro-config/src/tests/resolve_run.rs index 3d8eb9ac0..c691c71f4 100644 --- a/lib/crates/fabro-config/src/tests/resolve_run.rs +++ b/lib/crates/fabro-config/src/tests/resolve_run.rs @@ -1,5 +1,7 @@ use fabro_types::settings::InterpString; -use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode}; +use fabro_types::settings::run::{ + ApprovalMode, EnvironmentNetworkMode, EnvironmentProvider, RunGoal, RunMode, +}; use crate::{SettingsLayer, WorkflowSettingsBuilder}; @@ -43,16 +45,23 @@ fn resolves_run_defaults_from_empty_settings() { assert_eq!(settings.execution.mode, RunMode::Normal); assert_eq!(settings.execution.approval, ApprovalMode::Prompt); assert_eq!(settings.prepare.timeout_ms, 300_000); - assert_eq!(settings.sandbox.provider, "docker"); - assert!(settings.sandbox.stop_on_terminal); - let docker = settings - .sandbox - .docker - .as_ref() - .expect("defaults should provide docker settings"); - assert_eq!(docker.image, "buildpack-deps:noble"); - assert_eq!(docker.memory_limit, Some(4_000_000_000)); - assert_eq!(docker.cpu_quota, Some(200_000)); + assert_eq!(settings.environment.id, "default"); + assert_eq!(settings.environment.provider, EnvironmentProvider::Docker); + assert_eq!( + settings.environment.image.reference.as_deref(), + Some("buildpack-deps:noble") + ); + assert_eq!(settings.environment.resources.cpu, Some(2)); + assert_eq!( + settings + .environment + .resources + .memory + .map(|size| size.as_bytes()), + Some(4_000_000_000) + ); + assert!(!settings.environment.lifecycle.preserve); + assert!(settings.environment.lifecycle.stop_on_terminal); assert!(settings.clone.enabled); assert!(settings.run_branch.enabled); assert!(settings.run_branch.push); @@ -62,33 +71,98 @@ fn resolves_run_defaults_from_empty_settings() { } #[test] -fn resolves_daytona_volume_mounts() { +fn resolves_named_daytona_environment_and_run_overrides() { let settings = WorkflowSettingsBuilder::from_toml( r#" _version = 1 -[run.sandbox] +[run.environment] +id = "fabro-dev" + +[run.environment.resources] +memory = "32GB" + +[run.environment.lifecycle] +preserve = true + +[environments.fabro-dev] provider = "daytona" -[[run.sandbox.daytona.volumes]] -volume_id = "vol_auth" +[environments.fabro-dev.image] +ref = "fabro-v11" + +[environments.fabro-dev.resources] +cpu = 8 +memory = "16GB" +disk = "20GB" + +[environments.fabro-dev.network] +mode = "cidr_allow_list" +allow = ["10.0.0.0/8"] + +[environments.fabro-dev.lifecycle] +preserve = false +stop_on_terminal = true +auto_stop = "30m" + +[environments.fabro-dev.labels] +repo = "fabro-sh/fabro" + +[[environments.fabro-dev.volumes]] +id = "vol_auth" mount_path = "/home/daytona/.config" subpath = "agents" + +[environments.fabro-dev.env] +NODE_ENV = "development" "#, ) - .expect("daytona volume mount should resolve") - .run; + .expect("daytona environment should resolve"); - let daytona = settings - .sandbox - .daytona - .as_ref() - .expect("daytona settings should resolve"); + assert!(settings.environments.contains_key("fabro-dev")); + let environment = settings.run.environment; - assert_eq!(daytona.volumes.len(), 1); - assert_eq!(daytona.volumes[0].volume_id, "vol_auth"); - assert_eq!(daytona.volumes[0].mount_path, "/home/daytona/.config"); - assert_eq!(daytona.volumes[0].subpath.as_deref(), Some("agents")); + 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.resources.cpu, Some(8)); + assert_eq!( + environment.resources.memory.map(|size| size.as_bytes()), + Some(32_000_000_000) + ); + assert_eq!( + environment.resources.disk.map(|size| size.as_bytes()), + Some(20_000_000_000) + ); + assert_eq!( + environment.network.mode, + EnvironmentNetworkMode::CidrAllowList + ); + assert_eq!(environment.network.allow, vec!["10.0.0.0/8"]); + assert!(environment.lifecycle.preserve); + assert_eq!( + environment + .lifecycle + .auto_stop + .map(|duration| duration.as_std().as_secs()), + Some(1800) + ); + assert_eq!( + environment.labels.get("repo").map(String::as_str), + Some("fabro-sh/fabro") + ); + assert_eq!(environment.volumes.len(), 1); + assert_eq!(environment.volumes[0].id, "vol_auth"); + assert_eq!(environment.volumes[0].mount_path, "/home/daytona/.config"); + assert_eq!(environment.volumes[0].subpath.as_deref(), Some("agents")); + assert_eq!( + environment + .env + .get("NODE_ENV") + .map(InterpString::as_source) + .as_deref(), + Some("development") + ); } #[test] @@ -181,19 +255,19 @@ enabled = true } #[test] -fn provider_skip_clone_is_rejected() { - let err = r" +fn legacy_run_sandbox_is_rejected() { + let err = r#" _version = 1 -[run.sandbox.docker] -skip_clone = true -" +[run.sandbox] +provider = "local" +"# .parse::() - .expect_err("provider-level skip_clone should be unknown"); + .expect_err("legacy run.sandbox should be unknown"); let message = err.to_string(); assert!( - message.contains("skip_clone") || message.contains("unknown field"), - "expected unknown-field error mentioning skip_clone, got: {message}" + message.contains("sandbox") || message.contains("unknown field"), + "expected unknown-field error mentioning sandbox, got: {message}" ); } @@ -289,31 +363,131 @@ fn resolves_explicit_stop_on_terminal_false() { r" _version = 1 -[run.sandbox] +[run.environment.lifecycle] stop_on_terminal = false ", ) - .expect("sandbox stop_on_terminal setting should resolve") + .expect("environment stop_on_terminal setting should resolve") .run; - assert!(!settings.sandbox.stop_on_terminal); + assert!(!settings.environment.lifecycle.stop_on_terminal); } #[test] -fn resolves_minimal_local_provider_without_docker_table() { +fn resolves_minimal_local_environment() { let settings = WorkflowSettingsBuilder::from_toml( r#" _version = 1 -[run.sandbox] +[run.environment] +id = "host" + +[environments.host] provider = "local" "#, ) - .expect("minimal local sandbox settings should resolve") + .expect("minimal local environment settings should resolve") .run; - assert_eq!(settings.sandbox.provider, "local"); - assert!(settings.sandbox.docker.is_some()); + assert_eq!(settings.environment.id, "host"); + assert_eq!(settings.environment.provider, EnvironmentProvider::Local); + assert!(settings.environment.image.reference.is_none()); +} + +#[test] +fn missing_environment_slug_errors() { + let err = WorkflowSettingsBuilder::from_toml( + r#" +_version = 1 + +[run.environment] +id = "missing" +"#, + ) + .expect_err("missing selected environment should error"); + + let message = err.to_string(); + assert!( + message.contains("run.environment.id") && message.contains("missing"), + "expected missing environment diagnostic, got: {message}" + ); +} + +#[test] +fn docker_cidr_allow_list_errors() { + let err = WorkflowSettingsBuilder::from_toml( + r#" +_version = 1 + +[run.environment] +id = "locked" + +[environments.locked] +provider = "docker" + +[environments.locked.network] +mode = "cidr_allow_list" +allow = ["10.0.0.0/8"] +"#, + ) + .expect_err("docker cannot enforce cidr allow list"); + + let message = err.to_string(); + assert!( + message.contains("run.environment.network.mode") && message.contains("CIDR allow-list"), + "expected docker CIDR capability diagnostic, got: {message}" + ); +} + +#[test] +fn local_blocked_network_errors() { + let err = WorkflowSettingsBuilder::from_toml( + r#" +_version = 1 + +[run.environment] +id = "host" + +[environments.host] +provider = "local" + +[environments.host.network] +mode = "block" +"#, + ) + .expect_err("local cannot enforce blocked networking"); + + let message = err.to_string(); + assert!( + message.contains("run.environment.network.mode") + && message.contains("local environments cannot enforce"), + "expected local blocked-network diagnostic, got: {message}" + ); +} + +#[test] +fn daytona_dockerfile_without_image_ref_errors() { + let err = WorkflowSettingsBuilder::from_toml( + r#" +_version = 1 + +[run.environment] +id = "cloud" + +[environments.cloud] +provider = "daytona" + +[environments.cloud.image] +dockerfile = { path = "Dockerfile" } +"#, + ) + .expect_err("daytona dockerfile needs a snapshot name"); + + let message = err.to_string(); + assert!( + message.contains("image.ref"), + "expected daytona dockerfile/image.ref diagnostic, got: {message}" + ); } #[test] diff --git a/lib/crates/fabro-manifest/src/lib.rs b/lib/crates/fabro-manifest/src/lib.rs index f16cc2d92..d13c8908d 100644 --- a/lib/crates/fabro-manifest/src/lib.rs +++ b/lib/crates/fabro-manifest/src/lib.rs @@ -12,8 +12,9 @@ use fabro_api::types; use fabro_config::project::{self, WorkflowLocation, discover_project_config}; use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace}; use fabro_config::{ - CliLayer, DaytonaDockerfileLayer, DockerSandboxLayer, ReplaceMap, RunExecutionLayer, - RunGoalLayer, RunLayer, RunModelLayer, RunSandboxLayer, WorkflowSettingsBuilder, + CliLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLifecycleLayer, + ReplaceMap, RunEnvironmentLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, + SettingsLayer, WorkflowSettingsBuilder, }; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; @@ -58,7 +59,7 @@ pub struct RunOverrideInput<'a> { pub goal: Option<&'a str>, pub model: Option<&'a str>, pub provider: Option<&'a str>, - pub sandbox: Option<&'a str>, + pub environment: Option<&'a str>, pub docker_image: Option<&'a str>, pub preserve_sandbox: Option, pub dry_run: Option, @@ -77,17 +78,22 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer { fallbacks: Vec::new(), controls: None, }); - let sandbox = (input.sandbox.is_some() + let environment = (input.environment.is_some() || input.docker_image.is_some() || input.preserve_sandbox.is_some()) - .then(|| RunSandboxLayer { - provider: input.sandbox.map(ToOwned::to_owned), - docker: input.docker_image.map(|image| DockerSandboxLayer { - image: Some(image.to_string()), - ..DockerSandboxLayer::default() + .then(|| RunEnvironmentLayer { + id: input.environment.map(ToOwned::to_owned), + image: input.docker_image.map(|image| EnvironmentImageLayer { + reference: Some(image.to_string()), + ..EnvironmentImageLayer::default() }), - preserve: input.preserve_sandbox, - ..RunSandboxLayer::default() + lifecycle: input + .preserve_sandbox + .map(|preserve| EnvironmentLifecycleLayer { + preserve: Some(preserve), + ..EnvironmentLifecycleLayer::default() + }), + ..RunEnvironmentLayer::default() }); let execution = (input.dry_run.is_some() || input.auto_approve.is_some()).then(|| RunExecutionLayer { @@ -111,7 +117,7 @@ pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer { goal, metadata: ReplaceMap::from(input.labels), model, - sandbox, + environment, execution, ..RunLayer::default() } @@ -123,7 +129,7 @@ pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option, ) -> Result<()> { - let mut document: toml::Table = source.parse().context("Failed to parse run config TOML")?; - let run = document - .remove("run") - .map(toml::Value::try_into::) - .transpose() - .context("Failed to parse run config TOML")? - .unwrap_or_default(); - let dockerfile = run - .sandbox - .as_ref() - .and_then(|sandbox| sandbox.daytona.as_ref()) - .and_then(|daytona| daytona.snapshot.as_ref()) - .and_then(|snapshot| snapshot.dockerfile.as_ref()); + let layer = source + .parse::() + .context("Failed to parse run config TOML")?; + let absolute_config_path = cwd.join(config_path.as_path()); + let base_dir = absolute_config_path + .parent() + .unwrap_or_else(|| Path::new(".")); - let Some(DaytonaDockerfileLayer::Path { path }) = dockerfile else { + for environment in layer.environments.values() { + collect_environment_dockerfile( + files, + base_dir, + cwd, + config_path, + environment.image.as_ref(), + )?; + } + if let Some(run_environment) = layer.run.as_ref().and_then(|run| run.environment.as_ref()) { + collect_environment_dockerfile( + files, + base_dir, + cwd, + config_path, + run_environment.image.as_ref(), + )?; + } + Ok(()) +} + +fn collect_environment_dockerfile( + files: &mut HashMap, + base_dir: &Path, + cwd: &Path, + config_path: &ManifestPath, + image: Option<&EnvironmentImageLayer>, +) -> Result<()> { + let dockerfile = image.and_then(|image| image.dockerfile.as_ref()); + let Some(EnvironmentDockerfileLayer::Path { path }) = dockerfile else { return Ok(()); }; - let absolute_config_path = cwd.join(config_path.as_path()); collect_bundled_file( files, - absolute_config_path - .parent() - .unwrap_or_else(|| Path::new(".")), + base_dir, cwd, path, types::ManifestFileRefType::Dockerfile, @@ -849,7 +875,7 @@ pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool { && args.model.is_none() && args.preserve_sandbox.is_none() && args.provider.is_none() - && args.sandbox.is_none() + && args.environment.is_none() && args.docker_image.is_none() && args.input.is_empty() && args.verbose.is_none() @@ -865,7 +891,7 @@ mod tests { goal: Some("ship it"), model: Some("gpt-5.4-mini"), provider: Some("openai"), - sandbox: Some("local"), + environment: Some("local"), docker_image: None, preserve_sandbox: Some(true), dry_run: Some(true), @@ -900,10 +926,20 @@ mod tests { "openai" ); assert_eq!( - overrides.sandbox.as_ref().unwrap().provider.as_deref(), + overrides.environment.as_ref().unwrap().id.as_deref(), Some("local") ); - assert_eq!(overrides.sandbox.as_ref().unwrap().preserve, Some(true)); + assert_eq!( + overrides + .environment + .as_ref() + .unwrap() + .lifecycle + .as_ref() + .unwrap() + .preserve, + Some(true) + ); assert_eq!( overrides.execution.as_ref().unwrap().mode, Some(RunMode::DryRun) diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index a71d9c748..9f80e83c0 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1041,8 +1041,9 @@ mod runs { use fabro_api::types::*; use fabro_types::settings::run::{ - DaytonaSettings, DaytonaSnapshotSettings, RunGoal, RunModelSettings, RunNamespace, - RunPrepareSettings, RunSandboxSettings, + EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentProvider, + EnvironmentResourcesSettings, EnvironmentSettings, RunEnvironmentSettings, RunGoal, + RunModelSettings, RunNamespace, RunPrepareSettings, }; use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace}; use fabro_types::{ @@ -1714,12 +1715,33 @@ mod runs { pub(super) fn settings() -> serde_json::Value { let settings = WorkflowSettings { - project: ProjectNamespace::default(), - workflow: WorkflowNamespace { + project: ProjectNamespace::default(), + workflow: WorkflowNamespace { graph: "workflow.fabro".into(), ..WorkflowNamespace::default() }, - run: RunNamespace { + environments: HashMap::from([("api-server".to_string(), EnvironmentSettings { + provider: EnvironmentProvider::Daytona, + image: EnvironmentImageSettings { + reference: Some("api-server-dev".into()), + dockerfile: None, + }, + resources: EnvironmentResourcesSettings { + cpu: Some(4), + memory: Some(fabro_types::settings::Size::from_gigabytes(8)), + disk: Some(fabro_types::settings::Size::from_gigabytes(10)), + }, + lifecycle: EnvironmentLifecycleSettings { + preserve: false, + stop_on_terminal: true, + auto_stop: Some( + "60m".parse().expect("hardcoded demo duration should parse"), + ), + }, + labels: HashMap::from([("project".to_string(), "api-server".to_string())]), + ..EnvironmentSettings::default() + })]), + run: RunNamespace { goal: Some(RunGoal::Inline(InterpString::parse( "Add rate limiting to auth endpoints", ))), @@ -1733,30 +1755,30 @@ mod runs { commands: vec!["bun install".into(), "bun run typecheck".into()], timeout_ms: 120_000, }, - sandbox: RunSandboxSettings { - provider: "daytona".into(), - preserve: false, - stop_on_terminal: true, - devcontainer: false, - env: HashMap::new(), - docker: None, - daytona: Some(DaytonaSettings { - auto_stop_interval: Some(60), - labels: HashMap::from([( - "project".to_string(), - "api-server".to_string(), - )]), - volumes: Vec::new(), - snapshot: Some(DaytonaSnapshotSettings { - name: "api-server-dev".into(), - cpu: Some(4), - memory_gb: Some(8), - disk_gb: Some(10), + environment: RunEnvironmentSettings::from_environment( + "api-server".to_string(), + EnvironmentSettings { + provider: EnvironmentProvider::Daytona, + image: EnvironmentImageSettings { + reference: Some("api-server-dev".into()), dockerfile: None, - }), - network: None, - }), - }, + }, + resources: EnvironmentResourcesSettings { + cpu: Some(4), + memory: Some(fabro_types::settings::Size::from_gigabytes(8)), + disk: Some(fabro_types::settings::Size::from_gigabytes(10)), + }, + lifecycle: EnvironmentLifecycleSettings { + preserve: false, + stop_on_terminal: true, + auto_stop: Some( + "60m".parse().expect("hardcoded demo duration should parse"), + ), + }, + labels: HashMap::from([("project".to_string(), "api-server".to_string())]), + ..EnvironmentSettings::default() + }, + ), ..RunNamespace::default() }, }; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 4b8216a23..3b8474d51 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -7,10 +7,10 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use fabro_api::types; use fabro_auth::auth_issue_message; -use fabro_config::run::parse_run_layer_from_settings_toml; use fabro_config::{ - CliLayer, CliOutputLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder, - parse_input_overrides, parse_labels, + CliLayer, CliOutputLayer, EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, + MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder, parse_input_overrides, + parse_labels, }; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; @@ -27,8 +27,8 @@ use fabro_static::EnvVars; use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ - DaytonaNetworkLayer, DaytonaSettings, DockerSettings, DockerfileSource, RunGoal, RunMode, - RunNamespace, + DockerfileSource, EnvironmentNetworkMode, EnvironmentProvider, RunEnvironmentSettings, RunGoal, + RunMode, RunNamespace, }; use fabro_types::{ManifestPath, RunId, WorkflowSettings}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; @@ -76,6 +76,18 @@ pub(crate) fn manifest_run_defaults(run: Option<&RunLayer>) -> RunLayer { pub(crate) fn prepare_manifest( manifest_run_defaults: &RunLayer, manifest: &types::RunManifest, +) -> Result { + prepare_manifest_with_environment_defaults( + manifest_run_defaults, + &MergeMap::default(), + manifest, + ) +} + +pub(crate) fn prepare_manifest_with_environment_defaults( + manifest_run_defaults: &RunLayer, + manifest_environment_defaults: &MergeMap, + manifest: &types::RunManifest, ) -> Result { if manifest.version != 1 { bail!("unsupported manifest version {}", manifest.version); @@ -93,8 +105,10 @@ pub(crate) fn prepare_manifest( let args_overrides = manifest_args_overrides(manifest.args.as_ref()).context("failed to parse manifest args")?; - let mut workflow_settings_builder = - WorkflowSettingsBuilder::new().server_run_defaults(manifest_run_defaults.clone()); + let mut workflow_settings_builder = WorkflowSettingsBuilder::new().server_manifest_defaults( + manifest_run_defaults.clone(), + manifest_environment_defaults.clone(), + ); if let Some(run) = args_overrides.run { workflow_settings_builder = workflow_settings_builder.run_overrides(run); } @@ -102,9 +116,12 @@ pub(crate) fn prepare_manifest( workflow_settings_builder = workflow_settings_builder.cli_overrides(cli); } if let Some(config) = workflow_input.config.as_ref() { - let workflow_run_layer = workflow_run_layer_with_resolved_dockerfile(&workflow_input)?; - workflow_settings_builder = workflow_settings_builder - .workflow_toml_with_run_layer(&config.source, workflow_run_layer)?; + let layer = settings_layer_with_resolved_dockerfiles( + &config.source, + &config.path, + &workflow_input.files, + )?; + workflow_settings_builder = workflow_settings_builder.workflow_layer(layer); } for config in manifest .configs @@ -112,16 +129,13 @@ pub(crate) fn prepare_manifest( .filter(|config| config.type_ == types::ManifestConfigType::Project) { if let Some(source) = config.source.as_deref() { - let mut run = parse_run_layer_from_settings_toml(source) - .context("Failed to parse project config TOML")?; - if run_has_path_dockerfile(&run) { - let config_path = manifest_project_config_path(config, &cwd)?; - resolve_manifest_dockerfile(&mut run, &config_path, &workflow_input.files)?; - workflow_settings_builder = - workflow_settings_builder.project_toml_with_run_layer(source, run)?; - } else { - workflow_settings_builder = workflow_settings_builder.project_toml(source)?; - } + let config_path = manifest_project_config_path(config, &cwd)?; + let layer = settings_layer_with_resolved_dockerfiles( + source, + &config_path, + &workflow_input.files, + )?; + workflow_settings_builder = workflow_settings_builder.project_layer(layer); } } for config in manifest @@ -316,21 +330,19 @@ fn workflow_files_from_manifest( Ok(bundled) } -fn workflow_run_layer_with_resolved_dockerfile(workflow: &BundledWorkflow) -> Result { - let config = workflow - .config - .as_ref() - .expect("workflow config should exist before resolving its run layer"); +fn settings_layer_with_resolved_dockerfiles( + source: &str, + config_path: &ManifestPath, + files: &HashMap, +) -> Result { // Parse via `SettingsLayer` so unknown nested keys (like a stale // `[server.integrations.github.permissions]` after the move to - // `[run.integrations.github.permissions]`) trip - // `deny_unknown_fields`. Other valid top-level domains - // (`_version`, `[workflow]`, `[server.*]`) parse cleanly and the - // builder ignores everything outside `[run]` for this code path. - let mut run = parse_run_layer_from_settings_toml(&config.source) + // `[run.integrations.github.permissions]`) trip `deny_unknown_fields`. + let mut layer = source + .parse::() .context("Failed to parse run config TOML")?; - resolve_manifest_dockerfile(&mut run, &config.path, &workflow.files)?; - Ok(run) + resolve_manifest_dockerfiles(&mut layer, config_path, files)?; + Ok(layer) } fn manifest_args_overrides( @@ -344,7 +356,7 @@ fn manifest_args_overrides( goal: None, model: args.model.as_deref(), provider: args.provider.as_deref(), - sandbox: args.sandbox.as_deref(), + environment: args.environment.as_deref(), docker_image: args.docker_image.as_deref(), preserve_sandbox: args.preserve_sandbox, dry_run: args.dry_run, @@ -401,21 +413,37 @@ fn process_env_var(name: &str) -> Option { std::env::var(name).ok() } -fn resolve_manifest_dockerfile( - run: &mut RunLayer, +fn resolve_manifest_dockerfiles( + layer: &mut SettingsLayer, config_path: &ManifestPath, files: &HashMap, ) -> Result<()> { - let source = run - .sandbox + if let Some(image) = layer + .run .as_mut() - .and_then(|sandbox| sandbox.daytona.as_mut()) - .and_then(|daytona| daytona.snapshot.as_mut()) - .and_then(|snapshot| snapshot.dockerfile.as_mut()); + .and_then(|run| run.environment.as_mut()) + .and_then(|environment| environment.image.as_mut()) + { + resolve_manifest_dockerfile(image, config_path, files)?; + } + for environment in layer.environments.values_mut() { + if let Some(image) = environment.image.as_mut() { + resolve_manifest_dockerfile(image, config_path, files)?; + } + } + Ok(()) +} + +fn resolve_manifest_dockerfile( + image: &mut EnvironmentImageLayer, + config_path: &ManifestPath, + files: &HashMap, +) -> Result<()> { + let source = image.dockerfile.as_mut(); let Some(source) = source else { return Ok(()); }; - let DaytonaDockerfileLayer::Path { path } = &*source else { + let EnvironmentDockerfileLayer::Path { path } = &*source else { return Ok(()); }; let path_owned = path.clone(); @@ -425,21 +453,10 @@ fn resolve_manifest_dockerfile( .get(&manifest_path) .cloned() .ok_or_else(|| anyhow!("missing bundled dockerfile: {manifest_path}"))?; - *source = DaytonaDockerfileLayer::Inline(content); + *source = EnvironmentDockerfileLayer::Inline(content); Ok(()) } -fn run_has_path_dockerfile(run: &RunLayer) -> bool { - matches!( - run.sandbox - .as_ref() - .and_then(|sandbox| sandbox.daytona.as_ref()) - .and_then(|daytona| daytona.snapshot.as_ref()) - .and_then(|snapshot| snapshot.dockerfile.as_ref()), - Some(DaytonaDockerfileLayer::Path { .. }) - ) -} - fn manifest_project_config_path( config: &types::ManifestConfig, cwd: &Path, @@ -495,13 +512,14 @@ async fn build_preflight_report( let resolved_run = materialized.run; let server_settings = state.server_settings(); let github_integration = &server_settings.server.integrations.github; - let sandbox_provider = resolve_sandbox_provider(&resolved_run)?; + let sandbox_provider = resolve_sandbox_provider(&resolved_run); let sandbox_provider = if resolved_run.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() { SandboxProvider::Local } else { sandbox_provider }; + run_environment_capability_check(&mut checks, &resolved_run); let needs_github_credentials = sandbox_provider.is_clone_based() || resolved_run.integrations.github.is_token_requested(); let github_app = if needs_github_credentials { @@ -609,33 +627,18 @@ fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec Result { - Ok(Some(str::parse::( - settings.sandbox.provider.as_str(), - )) - .transpose() - .context("Invalid sandbox provider")? - .unwrap_or_default()) +fn resolve_sandbox_provider(settings: &RunNamespace) -> SandboxProvider { + SandboxProvider::from(settings.environment.provider) } fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig { - let mut config = settings - .sandbox - .daytona - .as_ref() - .map(|daytona| runtime_daytona_config(daytona, !settings.clone.enabled)) - .unwrap_or_default(); + let mut config = runtime_daytona_config(&settings.environment, !settings.clone.enabled); config.skip_clone = !settings.clone.enabled; config } fn resolve_docker_config(settings: &RunNamespace) -> DockerSandboxOptions { - let mut config = settings - .sandbox - .docker - .as_ref() - .map(|docker| runtime_docker_config(docker, !settings.clone.enabled)) - .unwrap_or_default(); + let mut config = runtime_docker_config(&settings.environment, !settings.clone.enabled); config.skip_clone = !settings.clone.enabled; config } @@ -653,6 +656,66 @@ fn clone_disabled_for_provider(provider: SandboxProvider, resolved_run: &RunName } } +fn run_environment_capability_check(checks: &mut Vec, resolved_run: &RunNamespace) { + let warnings = environment_capability_warnings(resolved_run); + if warnings.is_empty() { + return; + } + checks.push(CheckResult { + name: "Environment Capabilities".into(), + status: CheckStatus::Warning, + summary: format!("{} unsupported hint(s) ignored", warnings.len()), + details: warnings + .into_iter() + .map(|text| CheckDetail { text, warn: true }) + .collect(), + remediation: None, + }); +} + +fn environment_capability_warnings(resolved_run: &RunNamespace) -> Vec { + let environment = &resolved_run.environment; + let mut warnings = Vec::new(); + match environment.provider { + EnvironmentProvider::Local => { + if environment.resources.cpu.is_some() + || environment.resources.memory.is_some() + || environment.resources.disk.is_some() + { + warnings.push("local provider ignores resource limits".to_string()); + } + if !environment.volumes.is_empty() { + warnings.push("local provider ignores volume mounts".to_string()); + } + if !environment.labels.is_empty() { + warnings.push("local provider ignores labels".to_string()); + } + if environment.lifecycle.auto_stop.is_some() { + warnings.push("local provider ignores lifecycle.auto_stop".to_string()); + } + } + EnvironmentProvider::Docker => { + if environment.resources.disk.is_some() { + warnings.push("docker provider ignores disk resource limits".to_string()); + } + if !environment.volumes.is_empty() { + warnings.push("docker provider ignores volume mounts".to_string()); + } + if !environment.labels.is_empty() { + warnings.push("docker provider ignores labels".to_string()); + } + if environment.lifecycle.auto_stop.is_some() { + warnings.push("docker provider ignores lifecycle.auto_stop".to_string()); + } + if environment.image.dockerfile.is_some() { + warnings.push("docker provider ignores image.dockerfile".to_string()); + } + } + EnvironmentProvider::Daytona => {} + } + warnings +} + fn repository_access_details(request: &GitRemoteRefCheck) -> Vec { let mut details = vec![CheckDetail::new(format!("Origin: {}", request.origin_url))]; if let Some(branch) = request.branch.as_ref() { @@ -1121,28 +1184,39 @@ fn resolve_model_provider( } } -fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> DaytonaConfig { +fn runtime_daytona_config(settings: &RunEnvironmentSettings, skip_clone: bool) -> DaytonaConfig { DaytonaConfig { - auto_stop_interval: settings.auto_stop_interval, + auto_stop_interval: settings + .lifecycle + .auto_stop + .map(|duration| duration_to_minutes_i32(duration.as_std())), labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()), volumes: settings .volumes .iter() .map(|volume| DaytonaVolumeMount { - volume_id: volume.volume_id.clone(), + volume_id: volume.id.clone(), mount_path: volume.mount_path.clone(), subpath: volume.subpath.clone(), }) .collect(), snapshot: settings - .snapshot + .image + .reference .as_ref() - .map(|snapshot| DaytonaSnapshotSettings { - name: snapshot.name.clone(), - cpu: snapshot.cpu, - memory: snapshot.memory_gb, - disk: snapshot.disk_gb, - dockerfile: snapshot + .map(|name| DaytonaSnapshotSettings { + name: name.clone(), + 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: settings + .image .dockerfile .as_ref() .map(|dockerfile| match dockerfile { @@ -1154,36 +1228,65 @@ fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> Dayto } }), }), - network: settings.network.as_ref().map(|network| match network { - DaytonaNetworkLayer::Block => DaytonaNetwork::Block, - DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, - DaytonaNetworkLayer::AllowList { allow_list } => { - DaytonaNetwork::AllowList(allow_list.clone()) + network: Some(match settings.network.mode { + EnvironmentNetworkMode::Block => DaytonaNetwork::Block, + EnvironmentNetworkMode::AllowAll => DaytonaNetwork::AllowAll, + EnvironmentNetworkMode::CidrAllowList => { + DaytonaNetwork::AllowList(settings.network.allow.clone()) } }), skip_clone, } } -fn runtime_docker_config(settings: &DockerSettings, skip_clone: bool) -> DockerSandboxOptions { +fn runtime_docker_config( + settings: &RunEnvironmentSettings, + skip_clone: bool, +) -> DockerSandboxOptions { let mut env_vars = settings - .env_vars + .env .iter() .map(|(key, value)| format!("{key}={}", resolve_interp(value))) .collect::>(); env_vars.sort(); + let default_options = DockerSandboxOptions::default(); DockerSandboxOptions { - image: settings.image.clone(), - network_mode: settings.network_mode.clone(), - memory_limit: settings.memory_limit, - cpu_quota: settings.cpu_quota, + image: settings + .image + .reference + .clone() + .unwrap_or(default_options.image), + network_mode: match settings.network.mode { + EnvironmentNetworkMode::Block => Some("none".to_string()), + EnvironmentNetworkMode::AllowAll | EnvironmentNetworkMode::CidrAllowList => { + default_options.network_mode + } + }, + memory_limit: settings + .resources + .memory + .and_then(|size| i64::try_from(size.as_bytes()).ok()), + cpu_quota: settings + .resources + .cpu + .map(|cpu| i64::from(cpu).saturating_mul(100_000)), env_vars, skip_clone, ..DockerSandboxOptions::default() } } +fn duration_to_minutes_i32(duration: Duration) -> i32 { + let minutes = duration.as_secs() / 60; + i32::try_from(minutes).unwrap_or(i32::MAX) +} + +fn size_to_gb_i32(bytes: u64) -> i32 { + let gb = bytes / 1_000_000_000; + i32::try_from(gb).unwrap_or(i32::MAX) +} + async fn run_github_token_check( checks: &mut Vec, prepared: &PreparedManifest, @@ -1464,7 +1567,10 @@ mod tests { r#" _version = 1 -[run.sandbox] +[run.environment] +id = "selected" + +[environments.selected] provider = "{provider}" [run.clone] @@ -1493,14 +1599,17 @@ enabled = {clone_enabled} #[test] fn runtime_daytona_config_preserves_volume_mounts() { - let settings = DaytonaSettings { - volumes: vec![fabro_types::settings::run::DaytonaVolumeSettings { - volume_id: "vol_auth".to_string(), - mount_path: "/home/daytona/.config".to_string(), - subpath: Some("agents".to_string()), - }], - ..DaytonaSettings::default() - }; + let settings = RunEnvironmentSettings::from_environment( + "cloud".to_string(), + fabro_types::settings::run::EnvironmentSettings { + volumes: vec![fabro_types::settings::run::EnvironmentVolumeSettings { + id: "vol_auth".to_string(), + mount_path: "/home/daytona/.config".to_string(), + subpath: Some("agents".to_string()), + }], + ..fabro_types::settings::run::EnvironmentSettings::default() + }, + ); let config = runtime_daytona_config(&settings, false); @@ -1509,7 +1618,6 @@ enabled = {clone_enabled} assert_eq!(config.volumes[0].mount_path, "/home/daytona/.config"); assert_eq!(config.volumes[0].subpath.as_deref(), Some("agents")); } - #[test] fn prepare_manifest_inlines_project_config_daytona_dockerfile_from_bundle() { let mut manifest = minimal_manifest(); @@ -1518,11 +1626,14 @@ enabled = {clone_enabled} source: Some( r#"_version = 1 -[run.sandbox] +[run.environment] +id = "cloud" + +[environments.cloud] provider = "daytona" -[run.sandbox.daytona.snapshot] -name = "fabro-test" +[environments.cloud.image] +ref = "fabro-test" dockerfile = { path = "Dockerfile" } "# .to_string(), @@ -1552,11 +1663,10 @@ dockerfile = { path = "Dockerfile" } let dockerfile = prepared .settings .run - .sandbox - .daytona + .environment + .image + .dockerfile .as_ref() - .and_then(|daytona| daytona.snapshot.as_ref()) - .and_then(|snapshot| snapshot.dockerfile.as_ref()) .expect("project Dockerfile should resolve"); match dockerfile { DockerfileSource::Inline(value) => assert_eq!(value, "FROM ubuntu:24.04\n"), @@ -1574,8 +1684,14 @@ dockerfile = { path = "Dockerfile" } source: Some( r#"_version = 1 -[run.sandbox.daytona.snapshot] -name = "fabro-test" +[run.environment] +id = "cloud" + +[environments.cloud] +provider = "daytona" + +[environments.cloud.image] +ref = "fabro-test" dockerfile = { path = "Dockerfile" } "# .to_string(), @@ -1844,7 +1960,7 @@ root = "/srv/fabro" model: None, preserve_sandbox: None, provider: None, - sandbox: None, + environment: None, docker_image: None, input: Vec::new(), verbose: None, @@ -1877,7 +1993,7 @@ override = "server" model: None, preserve_sandbox: None, provider: None, - sandbox: None, + environment: None, docker_image: None, input: vec!["override=cli".to_string()], verbose: None, @@ -1972,8 +2088,8 @@ description = "Move the feature through review" team = "platform" priority = "high" -[run.sandbox] -provider = "local" +[run.environment] +id = "local" "# .to_string(), }); @@ -2114,8 +2230,8 @@ name = "Control Plane" path: "workflow.toml".to_string(), source: r#"_version = 1 -[run.sandbox] -provider = "local" +[run.environment] +id = "local" [run.integrations.github.permissions] issues = "read" @@ -2163,8 +2279,8 @@ _version = 1 [run.pull_request] enabled = true -[run.sandbox] -provider = "local" +[run.environment] +id = "local" "# .to_string(), ), @@ -2272,8 +2388,8 @@ name = "Project Config Name" r#" _version = 1 -[run.sandbox] -provider = "daytona" +[run.environment] +id = "daytona" "# .to_string(), ), @@ -2492,8 +2608,8 @@ digraph Demo { ); } - mod workflow_run_layer_with_resolved_dockerfile_tests { - //! `workflow_run_layer_with_resolved_dockerfile` parses bundled + mod settings_layer_with_resolved_dockerfiles_tests { + //! `settings_layer_with_resolved_dockerfiles` parses bundled //! workflow.toml through the strict `SettingsLayer` schema, so //! unknown fields anywhere in the document trip //! `deny_unknown_fields`. @@ -2501,7 +2617,7 @@ digraph Demo { use fabro_types::ManifestPath; use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig}; - use super::super::workflow_run_layer_with_resolved_dockerfile; + use super::super::settings_layer_with_resolved_dockerfiles; fn workflow_with_config(source: &str) -> BundledWorkflow { BundledWorkflow { @@ -2526,8 +2642,13 @@ issues = "read" "#, ); - let run = workflow_run_layer_with_resolved_dockerfile(&workflow) - .expect("workflow.toml should parse"); + let layer = settings_layer_with_resolved_dockerfiles( + &workflow.config.as_ref().unwrap().source, + &workflow.config.as_ref().unwrap().path, + &workflow.files, + ) + .expect("workflow.toml should parse"); + let run = layer.run.expect("run layer should be present"); let github = run .integrations .as_ref() @@ -2551,8 +2672,12 @@ issues = "read" "#, ); - let err = workflow_run_layer_with_resolved_dockerfile(&workflow) - .expect_err("stale [server.integrations.github.permissions] should be rejected"); + let err = settings_layer_with_resolved_dockerfiles( + &workflow.config.as_ref().unwrap().source, + &workflow.config.as_ref().unwrap().path, + &workflow.files, + ) + .expect_err("stale [server.integrations.github.permissions] should be rejected"); let message = format!("{err:#}"); assert!( message.contains("permissions") || message.contains("unknown field"), @@ -2573,8 +2698,13 @@ contents = "read" "#, ); - let run = workflow_run_layer_with_resolved_dockerfile(&workflow) - .expect("workflow + run blocks should parse"); + let layer = settings_layer_with_resolved_dockerfiles( + &workflow.config.as_ref().unwrap().source, + &workflow.config.as_ref().unwrap().path, + &workflow.files, + ) + .expect("workflow + run blocks should parse"); + let run = layer.run.expect("run layer should be present"); assert!(run.integrations.is_some()); } } diff --git a/lib/crates/fabro-server/src/run_tool_manifest.rs b/lib/crates/fabro-server/src/run_tool_manifest.rs index ad7b9804d..ec446a4bd 100644 --- a/lib/crates/fabro-server/src/run_tool_manifest.rs +++ b/lib/crates/fabro-server/src/run_tool_manifest.rs @@ -62,7 +62,7 @@ pub fn run_tool_manifest_args(spec: &ValidatedCreateRunSpec) -> Option Option goal: spec.goal.as_deref(), model: spec.model.as_deref(), provider: spec.provider.as_deref(), - sandbox: spec.sandbox.as_deref(), + environment: spec.environment.as_deref(), docker_image: None, preserve_sandbox: spec.preserve_sandbox, dry_run: spec.dry_run, @@ -88,7 +88,7 @@ pub fn run_tool_run_overrides(spec: &ValidatedCreateRunSpec) -> Option (run.goal.is_some() || !run.metadata.is_empty() || run.model.is_some() - || run.sandbox.is_some() + || run.environment.is_some() || run.execution.is_some()) .then_some(run) } @@ -118,7 +118,7 @@ mod tests { labels: HashMap::new(), model: None, provider: None, - sandbox: None, + environment: None, dry_run: None, auto_approve: None, preserve_sandbox: None, @@ -143,7 +143,7 @@ mod tests { labels: HashMap::new(), model: None, provider: None, - sandbox: None, + environment: None, dry_run: None, auto_approve: None, preserve_sandbox: None, diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 95638826b..1fc8bae42 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -9,11 +9,10 @@ use clap::Args; use fabro_config::bind::{self, Bind, BindRequest}; use fabro_config::user::active_settings_path; use fabro_config::{ - RunLayer, RunModelLayer, RunSandboxLayer, ServerLayer, ServerWebLayer, Storage, + RunEnvironmentLayer, RunLayer, RunModelLayer, ServerLayer, ServerWebLayer, Storage, load_config_file, load_server_runtime_settings, }; use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV}; -use fabro_sandbox::SandboxProvider; use fabro_static::EnvVars; use fabro_types::ServerSettings; use fabro_types::settings::server::{GithubIntegrationStrategy, LogDestination, WebhookStrategy}; @@ -205,9 +204,9 @@ pub struct ServeArgs { #[arg(long)] pub provider: Option, - /// Sandbox for agent tools - #[arg(long, value_enum)] - pub sandbox: Option, + /// Named environment for agent tools + #[arg(long)] + pub environment: Option, /// Maximum number of concurrent run executions #[arg(long)] @@ -240,9 +239,11 @@ fn serve_overrides(args: &ServeArgs) -> (Option, Option) let model_layer = run.model.get_or_insert_with(RunModelLayer::default); model_layer.provider = Some(InterpString::parse(provider)); } - if let Some(sandbox) = args.sandbox { - let sandbox_layer = run.sandbox.get_or_insert_with(RunSandboxLayer::default); - sandbox_layer.provider = Some(sandbox.to_string()); + if let Some(environment) = args.environment.as_ref() { + let environment_layer = run + .environment + .get_or_insert_with(RunEnvironmentLayer::default); + environment_layer.id = Some(environment.clone()); } ( (run != RunLayer::default()).then_some(run), @@ -722,10 +723,11 @@ where effective_log_destination, ); let resolved_app_settings = ResolvedAppStateSettings { - server_settings: runtime_settings.server_settings, - manifest_run_defaults: runtime_settings.manifest_run_defaults, - manifest_run_settings: runtime_settings.manifest_run_settings, - llm_catalog_settings: runtime_settings.llm_catalog_settings, + server_settings: runtime_settings.server_settings, + manifest_run_defaults: runtime_settings.manifest_run_defaults, + manifest_environment_defaults: runtime_settings.manifest_environment_defaults, + manifest_run_settings: runtime_settings.manifest_run_settings, + llm_catalog_settings: runtime_settings.llm_catalog_settings, }; let resolved_server_settings = resolved_app_settings.server_settings.server.clone(); let (auth_mode, server_secrets) = resolve_startup( @@ -893,10 +895,12 @@ where .server_settings .with_storage_override(&data_dir_for_poll); ResolvedAppStateSettings { - server_settings: resolved.server_settings, - manifest_run_defaults: resolved.manifest_run_defaults, - manifest_run_settings: resolved.manifest_run_settings, - llm_catalog_settings: resolved.llm_catalog_settings, + server_settings: resolved.server_settings, + manifest_run_defaults: resolved.manifest_run_defaults, + manifest_environment_defaults: resolved + .manifest_environment_defaults, + manifest_run_settings: resolved.manifest_run_settings, + llm_catalog_settings: resolved.llm_catalog_settings, } }); match resolved { @@ -1276,6 +1280,7 @@ mod tests { manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults) .map_err(|err| fabro_util::error::SharedError::new(anyhow::Error::new(err))), manifest_run_defaults, + manifest_environment_defaults: fabro_config::MergeMap::default(), server_settings: server_settings(source), llm_catalog_settings: fabro_model::catalog::LlmCatalogSettings::default(), } @@ -1468,7 +1473,7 @@ destination = "file" bind: None, model: None, provider: None, - sandbox: None, + environment: None, web: true, no_web: false, max_concurrent_runs: None, @@ -1494,7 +1499,7 @@ destination = "file" bind: None, model: None, provider: None, - sandbox: None, + environment: None, web: false, no_web: true, max_concurrent_runs: None, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 9bbf93cff..adc0e2370 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -45,7 +45,7 @@ use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message}; #[cfg(test)] use fabro_config::RunSettingsBuilder; use fabro_config::daemon::ServerDaemon; -use fabro_config::{RunLayer, Storage}; +use fabro_config::{EnvironmentLayer, MergeMap, RunLayer, Storage}; use fabro_interview::{ Answer, AnswerSubmission, ControlInterviewer, Interviewer, Question, WorkerControlEnvelope, }; @@ -623,6 +623,7 @@ pub struct AppState { pub(super) server_secrets: ServerSecrets, pub(crate) llm_source: Arc, manifest_run_defaults: RwLock>, + manifest_environment_defaults: RwLock>>, manifest_run_settings: RwLock>, pub(crate) server_settings: RwLock>, catalog: RwLock>, @@ -734,10 +735,11 @@ pub(crate) struct AppStateConfig { #[derive(Clone)] pub(crate) struct ResolvedAppStateSettings { - pub(crate) server_settings: ServerSettings, - pub(crate) manifest_run_defaults: RunLayer, - pub(crate) manifest_run_settings: std::result::Result, - pub(crate) llm_catalog_settings: LlmCatalogSettings, + pub(crate) server_settings: ServerSettings, + pub(crate) manifest_run_defaults: RunLayer, + pub(crate) manifest_environment_defaults: MergeMap, + pub(crate) manifest_run_settings: std::result::Result, + pub(crate) llm_catalog_settings: LlmCatalogSettings, } fn accumulate_billing_rollup( @@ -784,6 +786,15 @@ impl AppState { ) } + pub(crate) fn manifest_environment_defaults(&self) -> Arc> { + Arc::clone( + &self + .manifest_environment_defaults + .read() + .expect("manifest environment defaults lock poisoned"), + ) + } + pub(crate) fn server_settings(&self) -> Arc { Arc::clone( &self @@ -1031,11 +1042,13 @@ impl AppState { let ResolvedAppStateSettings { server_settings, manifest_run_defaults, + manifest_environment_defaults, manifest_run_settings, llm_catalog_settings, } = resolved_settings; let server_settings = Arc::new(server_settings); let manifest_run_defaults = Arc::new(manifest_run_defaults); + let manifest_environment_defaults = Arc::new(manifest_environment_defaults); let catalog = Arc::new( Catalog::from_builtin_with_overrides(&llm_catalog_settings) .context("building LLM model catalog")?, @@ -1047,6 +1060,10 @@ impl AppState { .manifest_run_defaults .write() .expect("manifest run defaults lock poisoned") = manifest_run_defaults; + *self + .manifest_environment_defaults + .write() + .expect("manifest environment defaults lock poisoned") = manifest_environment_defaults; *self .manifest_run_settings .write() @@ -1663,7 +1680,7 @@ fn system_sandbox_provider( ) -> String { manifest_run_settings.as_ref().map_or_else( |_| SandboxProvider::default().to_string(), - |settings| settings.sandbox.provider.clone(), + |settings| settings.environment.provider.to_string(), ) } @@ -1766,6 +1783,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result, run_id: RunId) { let run_spec = persisted.run_spec(); let settings = &run_spec.settings.run; let clone_can_use_github_credentials = settings.execution.mode != RunMode::DryRun - && clone_sandbox_can_use_github_credentials(&settings.sandbox.provider) + && clone_sandbox_can_use_github_credentials(&settings.environment.provider.to_string()) && run_spec .repo_origin_url() .is_some_and(|origin| !origin.trim().is_empty()); diff --git a/lib/crates/fabro-server/src/server/handler/graph.rs b/lib/crates/fabro-server/src/server/handler/graph.rs index 96891dac9..1ed691b2f 100644 --- a/lib/crates/fabro-server/src/server/handler/graph.rs +++ b/lib/crates/fabro-server/src/server/handler/graph.rs @@ -41,11 +41,15 @@ async fn render_graph_from_manifest( Json(req): Json, ) -> Response { let manifest_run_defaults = state.manifest_run_defaults(); - let prepared = - match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req.manifest) { - Ok(prepared) => prepared, - Err(err) => return ApiError::bad_request(err.to_string()).into_response(), - }; + let manifest_environment_defaults = state.manifest_environment_defaults(); + let prepared = match run_manifest::prepare_manifest_with_environment_defaults( + manifest_run_defaults.as_ref(), + manifest_environment_defaults.as_ref(), + &req.manifest, + ) { + Ok(prepared) => prepared, + Err(err) => return ApiError::bad_request(err.to_string()).into_response(), + }; let validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) { Ok(validated) => validated, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index 99b2b94df..930d3b2cd 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -547,7 +547,12 @@ async fn create_run( Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; let manifest_run_defaults = state.manifest_run_defaults(); - let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) { + let manifest_environment_defaults = state.manifest_environment_defaults(); + let prepared = match run_manifest::prepare_manifest_with_environment_defaults( + manifest_run_defaults.as_ref(), + manifest_environment_defaults.as_ref(), + &req, + ) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; @@ -676,7 +681,12 @@ async fn run_preflight( Json(req): Json, ) -> Response { let manifest_run_defaults = state.manifest_run_defaults(); - let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) { + let manifest_environment_defaults = state.manifest_environment_defaults(); + let prepared = match run_manifest::prepare_manifest_with_environment_defaults( + manifest_run_defaults.as_ref(), + manifest_environment_defaults.as_ref(), + &req, + ) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; @@ -704,7 +714,12 @@ async fn validate_run_manifest( Json(req): Json, ) -> Response { let manifest_run_defaults = state.manifest_run_defaults(); - let prepared = match run_manifest::prepare_manifest(manifest_run_defaults.as_ref(), &req) { + let manifest_environment_defaults = state.manifest_environment_defaults(); + let prepared = match run_manifest::prepare_manifest_with_environment_defaults( + manifest_run_defaults.as_ref(), + manifest_environment_defaults.as_ref(), + &req, + ) { Ok(prepared) => prepared, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index a9ad98fd5..9bed0ae67 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -855,8 +855,8 @@ methods = ["dev-token"] [server.web] url = "http://new.example.com" -[run.sandbox] -provider = "invalid-provider" +[run.environment] +id = "missing" "#; state @@ -875,8 +875,8 @@ fn system_sandbox_provider_uses_manifest_defaults() { let source = r#" _version = 1 -[run.sandbox] -provider = "daytona" +[run.environment] +id = "daytona" "#; let manifest_run_settings = resolve_manifest_run_settings( &run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))), @@ -890,8 +890,8 @@ fn system_sandbox_provider_defaults_when_manifest_run_settings_do_not_resolve() let source = r#" _version = 1 -[run.sandbox] -provider = "invalid-provider" +[run.environment] +id = "missing" "#; let manifest_run_settings = resolve_manifest_run_settings( &run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))), @@ -9022,7 +9022,7 @@ async fn delete_run_with_preserved_sandbox_returns_handoff() { let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); let mut settings = fabro_types::WorkflowSettings::default(); - settings.run.sandbox.preserve = true; + settings.run.environment.lifecycle.preserve = true; let graph = Graph::new("test"); create_durable_run_with_events(&state, run_id, &[ @@ -9488,8 +9488,8 @@ mode = "dry_run" provider = "anthropic" name = "claude-sonnet-4-5" -[run.sandbox] -provider = "local" +[run.environment] +id = "local" [[run.hooks]] name = "snapshot-hook" @@ -10095,8 +10095,8 @@ script = "sleep 5" [run.prepare] timeout = "30s" -[run.sandbox] -provider = "local" +[run.environment] +id = "local" "#; let state = test_app_state_with_settings_and_registry_factory( server_settings_from_toml(source), diff --git a/lib/crates/fabro-server/src/test_support.rs b/lib/crates/fabro-server/src/test_support.rs index bf0cb7b1c..df65d247d 100644 --- a/lib/crates/fabro-server/src/test_support.rs +++ b/lib/crates/fabro-server/src/test_support.rs @@ -269,10 +269,12 @@ pub(crate) fn resolved_runtime_settings_for_tests( manifest_run_defaults: RunLayer, llm_catalog_settings: LlmCatalogSettings, ) -> ResolvedAppStateSettings { + let manifest_environment_defaults = fabro_config::MergeMap::default(); ResolvedAppStateSettings { manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults) .map_err(|err| SharedError::new(anyhow::Error::new(err))), manifest_run_defaults, + manifest_environment_defaults, server_settings, llm_catalog_settings, } diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index 7c0f2a79d..906c16be4 100644 --- a/lib/crates/fabro-server/tests/it/api/tcp.rs +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -85,7 +85,7 @@ async fn spawn_served_listener( no_web: true, model: None, provider: None, - sandbox: None, + environment: None, max_concurrent_runs: None, config: Some(config_path), #[cfg(debug_assertions)] diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 19d2c5355..01c903fb7 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -4,7 +4,7 @@ use std::time::Duration; use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; -use fabro_config::{RunLayer, RunSandboxLayer, ServerSettingsBuilder}; +use fabro_config::{RunEnvironmentLayer, RunLayer, ServerSettingsBuilder}; use fabro_server::server::{AppState, spawn_scheduler}; use fabro_server::test_support::{ TestAppStateBuilder, build_test_router, llm_catalog_settings_with_provider_base_url, @@ -94,9 +94,9 @@ pub(crate) fn test_app_state_with_options( pub(crate) fn test_settings() -> TestAppSettings { TestAppSettings { manifest_run_defaults: RunLayer { - sandbox: Some(RunSandboxLayer { - provider: Some("local".to_string()), - ..RunSandboxLayer::default() + environment: Some(RunEnvironmentLayer { + id: Some("default".to_string()), + ..RunEnvironmentLayer::default() }), ..RunLayer::default() }, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 0da6e957c..0d8dfaf7d 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -7,7 +7,7 @@ use fabro_types::run_event::{ RunFailedProps, StageCompletedProps, StagePromptProps, TodoCreatedProps, TodoDeletedProps, TodoUpdatedProps, }; -use fabro_types::settings::run::RunSandboxSettings; +use fabro_types::settings::run::{EnvironmentProvider, RunEnvironmentSettings}; use fabro_types::{ AgentBackend, AskFabro, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination, Conclusion, EventBody, FailureSignature, InterviewQuestionRecord, Outcome, @@ -535,27 +535,21 @@ fn projection_from_created(event: &EventEnvelope) -> Result { let mut projection = RunProjection::new(title, spec, stored.ts); projection.parent_id = props.parent_id; projection.web_url.clone_from(&props.web_url); - projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.sandbox)); + projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.environment)); Ok(projection) } -fn planned_sandbox(settings: &RunSandboxSettings) -> RunSandbox { - let provider = settings - .provider - .parse::() - .unwrap_or(SandboxProvider::Local); +fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox { + let provider = SandboxProvider::from(settings.provider); RunSandbox { provider, - image: settings - .docker - .as_ref() - .map(|docker| docker.image.clone()) + image: (settings.provider == EnvironmentProvider::Docker) + .then(|| settings.image.reference.clone()) + .flatten() .filter(|image| !image.is_empty()), - snapshot: settings - .daytona - .as_ref() - .and_then(|daytona| daytona.snapshot.as_ref()) - .map(|snapshot| snapshot.name.clone()), + snapshot: (settings.provider == EnvironmentProvider::Daytona) + .then(|| settings.image.reference.clone()) + .flatten(), runtime: None, } } diff --git a/lib/crates/fabro-tool/src/create.rs b/lib/crates/fabro-tool/src/create.rs index 0d283fc06..01e2f7062 100644 --- a/lib/crates/fabro-tool/src/create.rs +++ b/lib/crates/fabro-tool/src/create.rs @@ -166,12 +166,12 @@ impl JsonSchema for CreateRunSpecInput { ], "description": "Provider override for the run." }, - "sandbox": { + "environment": { "anyOf": [ { "type": "string" }, { "type": "null" } ], - "description": "Sandbox provider override for the run." + "description": "Named environment slug override for the run." }, "preserve_sandbox": { "anyOf": [ @@ -210,7 +210,7 @@ pub struct CreateRunSpec { pub auto_approve: Option, pub model: Option, pub provider: Option, - pub sandbox: Option, + pub environment: Option, pub preserve_sandbox: Option, pub start: Option, } @@ -272,7 +272,7 @@ pub struct ValidatedCreateRunSpec { pub auto_approve: Option, pub model: Option, pub provider: Option, - pub sandbox: Option, + pub environment: Option, pub preserve_sandbox: Option, pub start: Option, } @@ -314,7 +314,7 @@ impl TryFrom for ValidatedCreateRunSpec { auto_approve: None, model: None, provider: None, - sandbox: None, + environment: None, preserve_sandbox: None, start: None, }) @@ -378,7 +378,7 @@ impl TryFrom for ValidatedCreateRunSpec { auto_approve: spec.auto_approve, model: spec.model, provider: spec.provider, - sandbox: spec.sandbox, + environment: spec.environment, preserve_sandbox: spec.preserve_sandbox, start: spec.start, }) @@ -547,7 +547,7 @@ mod tests { auto_approve: None, model: None, provider: None, - sandbox: None, + environment: None, preserve_sandbox: None, start: None, }) @@ -692,7 +692,7 @@ mod tests { auto_approve: Some(true), model: None, provider: None, - sandbox: None, + environment: None, preserve_sandbox: None, start: Some(false), } @@ -742,7 +742,7 @@ mod tests { auto_approve: Some(true), model: None, provider: None, - sandbox: None, + environment: None, preserve_sandbox: None, start: Some(false), }) @@ -791,7 +791,7 @@ mod tests { auto_approve: Some(true), model: None, provider: None, - sandbox: None, + environment: None, preserve_sandbox: None, start: Some(false), } diff --git a/lib/crates/fabro-types/src/dense.rs b/lib/crates/fabro-types/src/dense.rs index 6ffacc92a..c3fe39689 100644 --- a/lib/crates/fabro-types/src/dense.rs +++ b/lib/crates/fabro-types/src/dense.rs @@ -4,8 +4,8 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::settings::{ - CliNamespace, InterpString, ObjectStoreSettings, ProjectNamespace, RunNamespace, - ServerNamespace, WorkflowNamespace, + CliNamespace, EnvironmentSettings, InterpString, ObjectStoreSettings, ProjectNamespace, + RunNamespace, ServerNamespace, WorkflowNamespace, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -47,9 +47,10 @@ pub struct UserSettings { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct WorkflowSettings { - pub project: ProjectNamespace, - pub workflow: WorkflowNamespace, - pub run: RunNamespace, + pub project: ProjectNamespace, + pub workflow: WorkflowNamespace, + pub environments: HashMap, + pub run: RunNamespace, } impl WorkflowSettings { diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 9b9d680cd..4ad60b494 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -34,12 +34,14 @@ pub use public_url::{ is_wildcard_host, replace_wildcard_host, validate_public_url, validate_public_url_with_label, }; pub use run::{ - ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerSettings, DockerfileSource, + ArtifactsSettings, DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings, + EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider, + EnvironmentResourcesSettings, EnvironmentSettings, EnvironmentVolumeSettings, GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings, McpTransport, NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings, - RunAgentSettings, RunCheckpointSettings, RunExecutionSettings, RunGitSettings, RunGoal, - RunIntegrationsGithubSettings, RunIntegrationsSettings, RunInterviewsSettings, - RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, + RunAgentSettings, RunCheckpointSettings, RunEnvironmentSettings, RunExecutionSettings, + RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings, + RunInterviewsSettings, RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings, RunScmSettings, ScmGitHubSettings, TlsMode, }; pub use server::{ diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index 80c890f9e..7b3c3450c 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -13,8 +13,10 @@ use std::time::Duration as StdDuration; use serde::ser::SerializeStruct; use serde::{Deserialize, Serialize}; +use super::duration::Duration; use super::interp::InterpString; use super::model_ref::ModelRef; +use super::size::Size; /// A structurally resolved `[run]` view for consumers. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -31,7 +33,7 @@ pub struct RunNamespace { pub clone: RunCloneSettings, pub run_branch: RunBranchSettings, pub meta_branch: RunMetaBranchSettings, - pub sandbox: RunSandboxSettings, + pub environment: RunEnvironmentSettings, pub notifications: HashMap, pub interviews: RunInterviewsSettings, pub agent: RunAgentSettings, @@ -61,7 +63,7 @@ impl Default for RunNamespace { clone: RunCloneSettings::default(), run_branch: RunBranchSettings::default(), meta_branch: RunMetaBranchSettings::default(), - sandbox: RunSandboxSettings::default(), + environment: RunEnvironmentSettings::default(), notifications: HashMap::new(), interviews: RunInterviewsSettings::default(), agent: RunAgentSettings::default(), @@ -287,62 +289,193 @@ impl Default for RunMetaBranchSettings { } } +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase", ascii_case_insensitive)] +pub enum EnvironmentProvider { + #[default] + Local, + Docker, + Daytona, +} + +impl EnvironmentProvider { + #[must_use] + pub fn is_local(self) -> bool { + matches!(self, Self::Local) + } + + #[must_use] + pub fn is_clone_based(self) -> bool { + matches!(self, Self::Docker | Self::Daytona) + } +} + +impl From for crate::SandboxProvider { + fn from(value: EnvironmentProvider) -> Self { + match value { + EnvironmentProvider::Local => Self::Local, + EnvironmentProvider::Docker => Self::Docker, + EnvironmentProvider::Daytona => Self::Daytona, + } + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::IntoStaticStr, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case", ascii_case_insensitive)] +pub enum EnvironmentNetworkMode { + #[default] + AllowAll, + Block, + CidrAllowList, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct EnvironmentImageSettings { + #[serde(rename = "ref")] + pub reference: Option, + pub dockerfile: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct EnvironmentResourcesSettings { + pub cpu: Option, + pub memory: Option, + pub disk: Option, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunSandboxSettings { - pub provider: String, +pub struct EnvironmentNetworkSettings { + pub mode: EnvironmentNetworkMode, + pub allow: Vec, +} + +impl Default for EnvironmentNetworkSettings { + fn default() -> Self { + Self { + mode: EnvironmentNetworkMode::AllowAll, + allow: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnvironmentLifecycleSettings { pub preserve: bool, #[serde(default = "default_stop_on_terminal")] pub stop_on_terminal: bool, - pub devcontainer: bool, - pub env: HashMap, - pub docker: Option, - pub daytona: Option, + pub auto_stop: Option, } fn default_stop_on_terminal() -> bool { true } -impl Default for RunSandboxSettings { +impl Default for EnvironmentLifecycleSettings { fn default() -> Self { Self { - provider: "local".to_string(), preserve: false, stop_on_terminal: true, - devcontainer: false, - env: HashMap::new(), - docker: None, - daytona: None, + auto_stop: None, } } } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct DockerSettings { - pub image: String, - pub network_mode: Option, - pub memory_limit: Option, - pub cpu_quota: Option, - pub env_vars: HashMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct DaytonaSettings { - pub auto_stop_interval: Option, - pub labels: HashMap, - #[serde(default)] - pub volumes: Vec, - pub snapshot: Option, - pub network: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct DaytonaVolumeSettings { - pub volume_id: String, +pub struct EnvironmentVolumeSettings { + pub id: String, pub mount_path: String, pub subpath: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EnvironmentSettings { + pub provider: EnvironmentProvider, + pub image: EnvironmentImageSettings, + pub resources: EnvironmentResourcesSettings, + pub network: EnvironmentNetworkSettings, + pub lifecycle: EnvironmentLifecycleSettings, + pub labels: HashMap, + pub volumes: Vec, + pub env: HashMap, +} + +impl Default for EnvironmentSettings { + fn default() -> Self { + Self { + provider: EnvironmentProvider::Local, + image: EnvironmentImageSettings::default(), + resources: EnvironmentResourcesSettings::default(), + network: EnvironmentNetworkSettings::default(), + lifecycle: EnvironmentLifecycleSettings::default(), + labels: HashMap::new(), + volumes: Vec::new(), + env: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunEnvironmentSettings { + pub id: String, + pub provider: EnvironmentProvider, + pub image: EnvironmentImageSettings, + pub resources: EnvironmentResourcesSettings, + pub network: EnvironmentNetworkSettings, + pub lifecycle: EnvironmentLifecycleSettings, + pub labels: HashMap, + pub volumes: Vec, + pub env: HashMap, +} + +impl RunEnvironmentSettings { + #[must_use] + pub fn from_environment(id: String, environment: EnvironmentSettings) -> Self { + Self { + id, + provider: environment.provider, + image: environment.image, + resources: environment.resources, + network: environment.network, + lifecycle: environment.lifecycle, + labels: environment.labels, + volumes: environment.volumes, + env: environment.env, + } + } +} + +impl Default for RunEnvironmentSettings { + fn default() -> Self { + Self::from_environment("default".to_string(), EnvironmentSettings::default()) + } +} + #[derive(Debug, Clone, PartialEq)] pub enum DockerfileSource { Inline(String), @@ -388,15 +521,6 @@ impl<'de> Deserialize<'de> for DockerfileSource { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DaytonaSnapshotSettings { - pub name: String, - pub cpu: Option, - pub memory_gb: Option, - pub disk_gb: Option, - pub dockerfile: Option, -} - #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct NotificationRouteSettings { pub enabled: bool, @@ -707,14 +831,6 @@ pub enum ApprovalMode { Auto, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -pub enum DaytonaNetworkLayer { - Block, - AllowAll, - AllowList { allow_list: Vec }, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AgentPermissions { diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 851420cf2..885c81ea0 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -11,9 +11,7 @@ use std::sync::Arc; use fabro_config::Storage; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, ProviderId}; -use fabro_sandbox::SandboxProvider; use fabro_store::Database; -use fabro_types::settings::run::{RunMode, RunNamespace}; use fabro_types::{ ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings, }; @@ -91,9 +89,6 @@ pub async fn create( cwd: request.cwd, }) .map_err(|err| Error::Parse(err.to_string()))?; - if resolved.settings.run.execution.mode != RunMode::DryRun { - validate_sandbox_provider(&resolved.settings.run)?; - } let labels = resolved.settings.combined_labels(); let settings = resolved.settings.clone(); @@ -277,15 +272,6 @@ fn store_error(err: impl std::fmt::Display) -> Error { Error::engine(err.to_string()) } -fn validate_sandbox_provider(run: &RunNamespace) -> Result<(), Error> { - run.sandbox - .provider - .parse::() - .map_err(|err| Error::Precondition(format!("Invalid sandbox provider: {err}")))?; - - Ok(()) -} - fn create_from_source( dot_source: &str, options: PersistCreateOptions, @@ -1130,53 +1116,6 @@ mod tests { } } - #[tokio::test] - async fn create_reports_workflow_settings_errors_with_rendered_message() { - let dir = tempfile::tempdir().unwrap(); - let storage_root = dir.path().join("storage"); - let store = memory_store(); - let err = create( - &store, - CreateRunInput { - workflow: WorkflowInput::DotSource { - source: MINIMAL_DOT.to_string(), - base_dir: None, - }, - settings: { - let mut settings = WorkflowSettings::default(); - settings.run.execution.mode = RunMode::Normal; - settings.run.sandbox.provider = "not-a-provider".to_string(); - settings - }, - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - submitted_manifest_bytes: None, - run_id: None, - title: None, - git: None, - fork_source_ref: None, - parent_id: None, - provenance: None, - configured_providers: Vec::new(), - web_url: None, - }, - storage_root, - test_catalog(), - ) - .await - .unwrap_err(); - - match err { - Error::Precondition(message) => { - assert!(message.contains("Invalid sandbox provider")); - assert!(!message.contains('\n')); - } - other => panic!("expected Precondition, got {other:?}"), - } - } - #[tokio::test] async fn create_persists_normalized_config_and_initial_state() { let dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 1a3e2f6cc..bda21541c 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -16,12 +16,12 @@ use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, SandboxProvider, SandboxSpec}; use fabro_static::EnvVars; use fabro_types::settings::run::{ - ApprovalMode, DaytonaNetworkLayer, DaytonaSettings, DockerSettings, - DockerfileSource as ResolvedDockerfileSource, HookDefinition as ResolvedHookDefinition, - HookEvent as ResolvedHookEvent, HookType as ResolvedHookType, - McpServerSettings as ResolvedMcpServerSettings, McpTransport as ResolvedMcpTransport, - PullRequestSettings, RunMode, RunModelSettings as ResolvedRunModelSettings, - RunNamespace as ResolvedRunSettings, TlsMode as ResolvedTlsMode, + ApprovalMode, DockerfileSource as ResolvedDockerfileSource, EnvironmentNetworkMode, + HookDefinition as ResolvedHookDefinition, HookEvent as ResolvedHookEvent, + HookType as ResolvedHookType, McpServerSettings as ResolvedMcpServerSettings, + McpTransport as ResolvedMcpTransport, PullRequestSettings, RunEnvironmentSettings, RunMode, + RunModelSettings as ResolvedRunModelSettings, RunNamespace as ResolvedRunSettings, + TlsMode as ResolvedTlsMode, }; use fabro_types::settings::{InterpString, ModelRegistry, ResolvedModelRef}; use fabro_types::{ManifestPath, RunId}; @@ -316,7 +316,7 @@ impl RunSession { let resolved = &settings.run; - let sandbox_provider = resolve_sandbox_provider(resolved)?; + let sandbox_provider = resolve_sandbox_provider(resolved); let sandbox_provider = if resolved.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() { SandboxProvider::Local @@ -366,7 +366,7 @@ impl RunSession { }; let toml_env: HashMap = resolved - .sandbox + .environment .env .iter() .map(|(k, v)| (k.clone(), resolve_interp(v))) @@ -380,10 +380,7 @@ impl RunSession { origin_url: record.repo_origin_url().map(str::to_string), }; - let devcontainer = resolved.sandbox.devcontainer.then(|| DevcontainerSpec { - enabled: true, - resolve_dir: working_directory.clone(), - }); + let devcontainer = None; let interviewer: Arc = if resolved.execution.approval == ApprovalMode::Auto { @@ -427,8 +424,8 @@ impl RunSession { git, github_app: services.github_app.clone(), registry_override: services.registry_override, - preserve_sandbox: resolved.sandbox.preserve, - stop_on_terminal: resolved.sandbox.stop_on_terminal, + preserve_sandbox: resolved.environment.lifecycle.preserve, + stop_on_terminal: resolved.environment.lifecycle.stop_on_terminal, pr_config, pr_github_app: services.github_app, pr_origin_url: record.repo_origin_url().map(str::to_string), @@ -515,33 +512,18 @@ async fn load_accepted_run_definition( serde_json::from_slice(&bytes).map_err(|err| Error::Parse(err.to_string())) } -fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> Result { - Some(str::parse::( - settings.sandbox.provider.as_str(), - )) - .transpose() - .map_err(|err| Error::Precondition(format!("Invalid sandbox provider: {err}")))? - .map_or_else(|| Ok(SandboxProvider::default()), Ok) +fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProvider { + SandboxProvider::from(settings.environment.provider) } fn resolve_daytona_config(settings: &ResolvedRunSettings) -> DaytonaConfig { - let mut config = settings - .sandbox - .daytona - .as_ref() - .map(|daytona| runtime_daytona_config(daytona, !settings.clone.enabled)) - .unwrap_or_default(); + let mut config = runtime_daytona_config(&settings.environment, !settings.clone.enabled); config.skip_clone = !settings.clone.enabled; config } fn resolve_docker_config(settings: &ResolvedRunSettings) -> DockerSandboxOptions { - let mut config = settings - .sandbox - .docker - .as_ref() - .map(|docker| runtime_docker_config(docker, !settings.clone.enabled)) - .unwrap_or_default(); + let mut config = runtime_docker_config(&settings.environment, !settings.clone.enabled); config.skip_clone = !settings.clone.enabled; config } @@ -676,28 +658,39 @@ fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings } } -fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> DaytonaConfig { +fn runtime_daytona_config(settings: &RunEnvironmentSettings, skip_clone: bool) -> DaytonaConfig { DaytonaConfig { - auto_stop_interval: settings.auto_stop_interval, + auto_stop_interval: settings + .lifecycle + .auto_stop + .map(|duration| duration_to_minutes_i32(duration.as_std())), labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()), volumes: settings .volumes .iter() .map(|volume| DaytonaVolumeMount { - volume_id: volume.volume_id.clone(), + volume_id: volume.id.clone(), mount_path: volume.mount_path.clone(), subpath: volume.subpath.clone(), }) .collect(), snapshot: settings - .snapshot + .image + .reference .as_ref() - .map(|snapshot| DaytonaSnapshotSettings { - name: snapshot.name.clone(), - cpu: snapshot.cpu, - memory: snapshot.memory_gb, - disk: snapshot.disk_gb, - dockerfile: snapshot + .map(|name| DaytonaSnapshotSettings { + name: name.clone(), + 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: settings + .image .dockerfile .as_ref() .map(|dockerfile| match dockerfile { @@ -709,36 +702,65 @@ fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> Dayto } }), }), - network: settings.network.as_ref().map(|network| match network { - DaytonaNetworkLayer::Block => DaytonaNetwork::Block, - DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, - DaytonaNetworkLayer::AllowList { allow_list } => { - DaytonaNetwork::AllowList(allow_list.clone()) + network: Some(match settings.network.mode { + EnvironmentNetworkMode::Block => DaytonaNetwork::Block, + EnvironmentNetworkMode::AllowAll => DaytonaNetwork::AllowAll, + EnvironmentNetworkMode::CidrAllowList => { + DaytonaNetwork::AllowList(settings.network.allow.clone()) } }), skip_clone, } } -fn runtime_docker_config(settings: &DockerSettings, skip_clone: bool) -> DockerSandboxOptions { +fn runtime_docker_config( + settings: &RunEnvironmentSettings, + skip_clone: bool, +) -> DockerSandboxOptions { let mut env_vars = settings - .env_vars + .env .iter() .map(|(key, value)| format!("{key}={}", resolve_interp(value))) .collect::>(); env_vars.sort(); + let default_options = DockerSandboxOptions::default(); DockerSandboxOptions { - image: settings.image.clone(), - network_mode: settings.network_mode.clone(), - memory_limit: settings.memory_limit, - cpu_quota: settings.cpu_quota, + image: settings + .image + .reference + .clone() + .unwrap_or(default_options.image), + network_mode: match settings.network.mode { + EnvironmentNetworkMode::Block => Some("none".to_string()), + EnvironmentNetworkMode::AllowAll | EnvironmentNetworkMode::CidrAllowList => { + default_options.network_mode + } + }, + memory_limit: settings + .resources + .memory + .and_then(|size| i64::try_from(size.as_bytes()).ok()), + cpu_quota: settings + .resources + .cpu + .map(|cpu| i64::from(cpu).saturating_mul(100_000)), env_vars, skip_clone, ..DockerSandboxOptions::default() } } +fn duration_to_minutes_i32(duration: Duration) -> i32 { + let minutes = duration.as_secs() / 60; + i32::try_from(minutes).unwrap_or(i32::MAX) +} + +fn size_to_gb_i32(bytes: u64) -> i32 { + let gb = bytes / 1_000_000_000; + i32::try_from(gb).unwrap_or(i32::MAX) +} + fn runtime_hook_definition(definition: &ResolvedHookDefinition) -> fabro_hooks::HookDefinition { fabro_hooks::HookDefinition { name: definition.name.clone(), @@ -1135,12 +1157,13 @@ mod tests { use chrono::Utc; use fabro_config::{ - DaytonaSandboxLayer, DaytonaVolumeLayer, RunCloneLayer, RunExecutionLayer, RunLayer, - RunSandboxLayer, WorkflowSettingsBuilder, + EnvironmentImageLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, + EnvironmentVolumeLayer, RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, RunLayer, + StickyMap, WorkflowSettingsBuilder, }; use fabro_store::Database; - use fabro_types::settings::ModelRef; use fabro_types::settings::run::RunMode; + use fabro_types::settings::{InterpString, ModelRef}; use fabro_types::{ManifestPath, WorkflowSettings, fixtures}; use object_store::memory::InMemory; @@ -1284,19 +1307,51 @@ reasoning = false assert!(resolve_daytona_config(&settings.run).skip_clone); } + #[test] + fn runtime_docker_config_maps_environment_hints() { + let settings = settings_from_run_layer(RunLayer { + environment: Some(RunEnvironmentLayer { + image: Some(EnvironmentImageLayer { + reference: Some("ubuntu:24.04".to_string()), + ..EnvironmentImageLayer::default() + }), + resources: Some(EnvironmentResourcesLayer { + cpu: Some(4), + memory: Some("2GB".parse().unwrap()), + disk: None, + }), + network: Some(EnvironmentNetworkLayer { + mode: Some("block".to_string()), + allow: Vec::new(), + }), + env: StickyMap::from(HashMap::from([( + "NODE_ENV".to_string(), + InterpString::parse("test"), + )])), + ..RunEnvironmentLayer::default() + }), + ..RunLayer::default() + }); + + let config = resolve_docker_config(&settings.run); + + assert_eq!(config.image, "ubuntu:24.04"); + assert_eq!(config.cpu_quota, Some(400_000)); + assert_eq!(config.memory_limit, Some(2_000_000_000)); + assert_eq!(config.network_mode.as_deref(), Some("none")); + assert_eq!(config.env_vars, vec!["NODE_ENV=test"]); + } + #[test] fn runtime_daytona_config_preserves_volume_mounts() { let settings = settings_from_run_layer(RunLayer { - sandbox: Some(RunSandboxLayer { - daytona: Some(DaytonaSandboxLayer { - volumes: Some(vec![DaytonaVolumeLayer { - volume_id: "vol_auth".to_string(), - mount_path: "/home/daytona/.config".to_string(), - subpath: Some("agents".to_string()), - }]), - ..DaytonaSandboxLayer::default() - }), - ..RunSandboxLayer::default() + environment: Some(RunEnvironmentLayer { + volumes: Some(vec![EnvironmentVolumeLayer { + id: "vol_auth".to_string(), + mount_path: "/home/daytona/.config".to_string(), + subpath: Some("agents".to_string()), + }]), + ..RunEnvironmentLayer::default() }), ..RunLayer::default() }); diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 653eea117..04ff5564a 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -67,11 +67,6 @@ models/create-completion-request.ts models/create-run-pull-request-request.ts models/create-run-session-request.ts models/create-secret-request.ts -models/daytona-network-layer-one-of-allow-list.ts -models/daytona-network-layer-one-of.ts -models/daytona-network-layer.ts -models/daytona-settings.ts -models/daytona-snapshot-settings.ts models/delete-run-response.ts models/delete-run-sandbox.ts models/delete-secret-request.ts @@ -90,10 +85,17 @@ models/dirty-status.ts models/disk-usage-response.ts models/disk-usage-run-row.ts models/disk-usage-summary-row.ts -models/docker-settings.ts models/dockerfile-source-inline.ts models/dockerfile-source-path.ts models/dockerfile-source.ts +models/environment-image-settings.ts +models/environment-lifecycle-settings.ts +models/environment-network-mode.ts +models/environment-network-settings.ts +models/environment-provider.ts +models/environment-resources-settings.ts +models/environment-settings.ts +models/environment-volume-settings.ts models/error-response-entry.ts models/error-response.ts models/event-envelope.ts @@ -280,6 +282,7 @@ models/run-commit.ts models/run-commits-meta.ts models/run-control-action.ts models/run-diff.ts +models/run-environment-settings.ts models/run-error.ts models/run-event-detail-response-content.ts models/run-event-detail-response-event.ts @@ -311,7 +314,6 @@ models/run-provenance.ts models/run-question.ts models/run-reference.ts models/run-sandbox-runtime.ts -models/run-sandbox-settings.ts models/run-sandbox.ts models/run-scm-settings.ts models/run-server-provenance.ts @@ -410,6 +412,10 @@ models/system-resources-response.ts models/system-run-counts.ts models/timeline-entry-response.ts models/tls-mode.ts +models/todo-list-kind.ts +models/todo-list-projection.ts +models/todo-projection.ts +models/todo-status.ts models/update-run-parent-request.ts models/update-run-request.ts models/user-response.ts @@ -425,4 +431,4 @@ models/workflow-ref.ts models/workflow-reference.ts models/workflow-schedule-summary.ts models/workflow-settings.ts -models/write-blob-response.ts \ No newline at end of file +models/write-blob-response.ts diff --git a/lib/packages/fabro-api-client/src/models/environment-image-settings.ts b/lib/packages/fabro-api-client/src/models/environment-image-settings.ts new file mode 100644 index 000000000..57b3f1026 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-image-settings.ts @@ -0,0 +1,23 @@ +/* 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 EnvironmentImageSettings { + 'ref': string | null; + 'dockerfile': DockerfileSource | null; +} diff --git a/lib/packages/fabro-api-client/src/models/environment-lifecycle-settings.ts b/lib/packages/fabro-api-client/src/models/environment-lifecycle-settings.ts new file mode 100644 index 000000000..f9a2426a9 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-lifecycle-settings.ts @@ -0,0 +1,21 @@ +/* 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. + */ + + + +export interface EnvironmentLifecycleSettings { + 'preserve': boolean; + 'stop_on_terminal': boolean; + 'auto_stop': string | null; +} diff --git a/lib/packages/fabro-api-client/src/models/environment-network-mode.ts b/lib/packages/fabro-api-client/src/models/environment-network-mode.ts new file mode 100644 index 000000000..ab4772098 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-network-mode.ts @@ -0,0 +1,24 @@ +/* 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. + */ + + + + +export const EnvironmentNetworkMode = { + ALLOW_ALL: 'allow_all', + BLOCK: 'block', + CIDR_ALLOW_LIST: 'cidr_allow_list' +} as const; + +export type EnvironmentNetworkMode = typeof EnvironmentNetworkMode[keyof typeof EnvironmentNetworkMode]; diff --git a/lib/packages/fabro-api-client/src/models/environment-network-settings.ts b/lib/packages/fabro-api-client/src/models/environment-network-settings.ts new file mode 100644 index 000000000..8bdf99fb6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-network-settings.ts @@ -0,0 +1,23 @@ +/* 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 { EnvironmentNetworkMode } from './environment-network-mode'; + +export interface EnvironmentNetworkSettings { + 'mode': EnvironmentNetworkMode; + 'allow': Array; +} diff --git a/lib/packages/fabro-api-client/src/models/environment-provider.ts b/lib/packages/fabro-api-client/src/models/environment-provider.ts new file mode 100644 index 000000000..0761c4b4c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-provider.ts @@ -0,0 +1,27 @@ +/* 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. + */ + + + +/** + * Desired environment provider. + */ + +export const EnvironmentProvider = { + LOCAL: 'local', + DOCKER: 'docker', + DAYTONA: 'daytona' +} as const; + +export type EnvironmentProvider = typeof EnvironmentProvider[keyof typeof EnvironmentProvider]; diff --git a/lib/packages/fabro-api-client/src/models/environment-resources-settings.ts b/lib/packages/fabro-api-client/src/models/environment-resources-settings.ts new file mode 100644 index 000000000..c17caaf36 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-resources-settings.ts @@ -0,0 +1,21 @@ +/* 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. + */ + + + +export interface EnvironmentResourcesSettings { + 'cpu': number | null; + 'memory': string | null; + 'disk': string | null; +} diff --git a/lib/packages/fabro-api-client/src/models/environment-settings.ts b/lib/packages/fabro-api-client/src/models/environment-settings.ts new file mode 100644 index 000000000..a0e4b2482 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-settings.ts @@ -0,0 +1,44 @@ +/* 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 { EnvironmentImageSettings } from './environment-image-settings'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentNetworkSettings } from './environment-network-settings'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentProvider } from './environment-provider'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentResourcesSettings } from './environment-resources-settings'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentVolumeSettings } from './environment-volume-settings'; + +export interface EnvironmentSettings { + 'provider': EnvironmentProvider; + 'image': EnvironmentImageSettings; + 'resources': EnvironmentResourcesSettings; + 'network': EnvironmentNetworkSettings; + 'lifecycle': EnvironmentLifecycleSettings; + 'labels': { [key: string]: string; }; + 'volumes': Array; + 'env': { [key: string]: string; }; +} diff --git a/lib/packages/fabro-api-client/src/models/environment-volume-settings.ts b/lib/packages/fabro-api-client/src/models/environment-volume-settings.ts new file mode 100644 index 000000000..c31ce5314 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/environment-volume-settings.ts @@ -0,0 +1,21 @@ +/* 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. + */ + + + +export interface EnvironmentVolumeSettings { + 'id': string; + 'mount_path': string; + 'subpath': string | null; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 23a07a598..ec0aaf216 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -44,11 +44,6 @@ export * from './create-completion-request'; export * from './create-run-pull-request-request'; export * from './create-run-session-request'; export * from './create-secret-request'; -export * from './daytona-network-layer'; -export * from './daytona-network-layer-one-of'; -export * from './daytona-network-layer-one-of-allow-list'; -export * from './daytona-settings'; -export * from './daytona-snapshot-settings'; export * from './delete-run-response'; export * from './delete-run-sandbox'; export * from './delete-secret-request'; @@ -67,10 +62,17 @@ export * from './dirty-status'; export * from './disk-usage-response'; export * from './disk-usage-run-row'; export * from './disk-usage-summary-row'; -export * from './docker-settings'; export * from './dockerfile-source'; export * from './dockerfile-source-inline'; export * from './dockerfile-source-path'; +export * from './environment-image-settings'; +export * from './environment-lifecycle-settings'; +export * from './environment-network-mode'; +export * from './environment-network-settings'; +export * from './environment-provider'; +export * from './environment-resources-settings'; +export * from './environment-settings'; +export * from './environment-volume-settings'; export * from './error-response'; export * from './error-response-entry'; export * from './event-envelope'; @@ -257,6 +259,7 @@ export * from './run-commit-person'; export * from './run-commits-meta'; export * from './run-control-action'; export * from './run-diff'; +export * from './run-environment-settings'; export * from './run-error'; export * from './run-event'; export * from './run-event-detail-response'; @@ -289,7 +292,6 @@ export * from './run-question'; export * from './run-reference'; export * from './run-sandbox'; export * from './run-sandbox-runtime'; -export * from './run-sandbox-settings'; export * from './run-scm-settings'; export * from './run-server-provenance'; export * from './run-spec'; @@ -405,4 +407,4 @@ export * from './workflow-ref'; export * from './workflow-reference'; export * from './workflow-schedule-summary'; export * from './workflow-settings'; -export * from './write-blob-response'; \ No newline at end of file +export * from './write-blob-response'; diff --git a/lib/packages/fabro-api-client/src/models/manifest-args.ts b/lib/packages/fabro-api-client/src/models/manifest-args.ts index 244109b3f..41fb58300 100644 --- a/lib/packages/fabro-api-client/src/models/manifest-args.ts +++ b/lib/packages/fabro-api-client/src/models/manifest-args.ts @@ -20,9 +20,12 @@ export interface ManifestArgs { 'model'?: string; 'provider'?: string; - 'sandbox'?: string; /** - * Per-run Docker sandbox image override. + * Named environment slug to select for the run. + */ + 'environment'?: string; + /** + * Per-run environment image override. */ 'docker_image'?: string; 'verbose'?: boolean; diff --git a/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts b/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts index 353e5f24d..4af156da7 100644 --- a/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts +++ b/lib/packages/fabro-api-client/src/models/run-checkpoint-settings.ts @@ -18,8 +18,6 @@ export interface RunCheckpointSettings { 'exclude_globs': Array; /** * When true, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks. Does not affect Fabro `[[run.hooks]]` or metadata-branch snapshots. Defaults to false. - * @type {boolean} - * @memberof RunCheckpointSettings */ 'skip_git_hooks': boolean; } diff --git a/lib/packages/fabro-api-client/src/models/run-environment-settings.ts b/lib/packages/fabro-api-client/src/models/run-environment-settings.ts new file mode 100644 index 000000000..1a669d91d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-environment-settings.ts @@ -0,0 +1,45 @@ +/* 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 { EnvironmentImageSettings } from './environment-image-settings'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentNetworkSettings } from './environment-network-settings'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentProvider } from './environment-provider'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentResourcesSettings } from './environment-resources-settings'; +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentVolumeSettings } from './environment-volume-settings'; + +export interface RunEnvironmentSettings { + 'id': string; + 'provider': EnvironmentProvider; + 'image': EnvironmentImageSettings; + 'resources': EnvironmentResourcesSettings; + 'network': EnvironmentNetworkSettings; + 'lifecycle': EnvironmentLifecycleSettings; + 'labels': { [key: string]: string; }; + 'volumes': Array; + 'env': { [key: string]: string; }; +} diff --git a/lib/packages/fabro-api-client/src/models/run-namespace.ts b/lib/packages/fabro-api-client/src/models/run-namespace.ts index 655714f13..9d3a003fd 100644 --- a/lib/packages/fabro-api-client/src/models/run-namespace.ts +++ b/lib/packages/fabro-api-client/src/models/run-namespace.ts @@ -39,6 +39,9 @@ import type { RunCheckpointSettings } from './run-checkpoint-settings'; import type { RunCloneSettings } from './run-clone-settings'; // May contain unused imports in some cases // @ts-ignore +import type { RunEnvironmentSettings } from './run-environment-settings'; +// May contain unused imports in some cases +// @ts-ignore import type { RunExecutionSettings } from './run-execution-settings'; // May contain unused imports in some cases // @ts-ignore @@ -63,9 +66,6 @@ import type { RunModelSettings } from './run-model-settings'; import type { RunPrepareSettings } from './run-prepare-settings'; // May contain unused imports in some cases // @ts-ignore -import type { RunSandboxSettings } from './run-sandbox-settings'; -// May contain unused imports in some cases -// @ts-ignore import type { RunScmSettings } from './run-scm-settings'; export interface RunNamespace { @@ -84,7 +84,7 @@ export interface RunNamespace { 'clone': RunCloneSettings; 'run_branch': RunBranchSettings; 'meta_branch': RunMetaBranchSettings; - 'sandbox': RunSandboxSettings; + 'environment': RunEnvironmentSettings; 'notifications': { [key: string]: NotificationRouteSettings; }; 'interviews': RunInterviewsSettings; 'agent': RunAgentSettings; diff --git a/lib/packages/fabro-api-client/src/models/run-projection.ts b/lib/packages/fabro-api-client/src/models/run-projection.ts index 0505c0cd0..0ab7b799f 100644 --- a/lib/packages/fabro-api-client/src/models/run-projection.ts +++ b/lib/packages/fabro-api-client/src/models/run-projection.ts @@ -87,4 +87,4 @@ export interface RunProjection { * Map from StageId (`node_id@visit`) to stage projection data. */ 'stages': { [key: string]: StageProjection; }; -} \ No newline at end of file +} diff --git a/lib/packages/fabro-api-client/src/models/workflow-settings.ts b/lib/packages/fabro-api-client/src/models/workflow-settings.ts index 7f1756c5f..7f5214295 100644 --- a/lib/packages/fabro-api-client/src/models/workflow-settings.ts +++ b/lib/packages/fabro-api-client/src/models/workflow-settings.ts @@ -13,6 +13,9 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { EnvironmentSettings } from './environment-settings'; // May contain unused imports in some cases // @ts-ignore import type { ProjectNamespace } from './project-namespace'; @@ -29,5 +32,6 @@ import type { WorkflowNamespace } from './workflow-namespace'; export interface WorkflowSettings { 'project': ProjectNamespace; 'workflow': WorkflowNamespace; + 'environments': { [key: string]: EnvironmentSettings; }; 'run': RunNamespace; }