From bd725704375ce34cbf9a1196b1c9911caa2aa4fc Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 22:41:57 -0400 Subject: [PATCH] =?UTF-8?q?Add=20provider-backed=20sandbox=20inventory=20A?= =?UTF-8?q?PI=20and=20rename=20SandboxProvider=20to=E2=80=A6=20(#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Exposes `GET /api/v1/sandboxes` and `GET /api/v1/sandboxes/{id}` endpoints that query sandbox inventory directly from configured providers (Docker, Daytona), independent of run projections. Also renames the existing `SandboxProvider` enum to `SandboxProviderKind` throughout the codebase to free the name for the new `SandboxProvider` trait. ### Plan Summary - **OpenAPI + types**: New `SandboxInfo`, `SandboxListResponse`, `SandboxListMeta`, `SandboxProviderLookupError`, and `SandboxProviderKind` schemas added to the API spec; canonical Rust DTOs added to `fabro-types`. - **Provider trait and registry**: `SandboxProvider` trait (`list`, `get`, `create`, `delete`) and `SandboxProviderRegistry` introduced in `fabro-sandbox/src/provider.rs`. Registry fans out calls across all configured providers and implements fail-soft semantics for list and conflict/unavailable detection for get. - **Provider implementations**: `DockerSandboxProvider` uses Bollard label-filtered container listing and per-inspect; `DaytonaSandboxProvider` uses the SDK with paginated label-filtered listing. Both verify `sh.fabro.managed=true`. - **Shared detail mapping**: Docker and Daytona inspect-to-`SandboxInfo` paths extracted into `docker_info_from_inspect` / `daytona_info_from_sdk_sandbox` so run-scoped `SandboxDetails` and inventory `SandboxInfo` share the same normalization logic. - **Monitoring UI**: `RunsInfo` now exposes `scheduler_slots_used`; the monitoring panel displays "slots used" instead of the raw active-run count. ## What changed and why **`SandboxProvider` → `SandboxProviderKind`** is a mechanical rename across ~20 call sites so the unqualified name `SandboxProvider` can be claimed by the new trait without collision. **Registry lookup semantics** for `get_managed_by_native_id`: | Outcome | HTTP | |---|---| | Exactly one provider matches | `200` | | All providers succeed, none match | `404` | | Two or more providers match the same id | `409` | | No match + at least one provider failed | `502` | List is always fail-soft: partial results are returned and failing providers appear in `meta.provider_errors`. **`DockerFields` / `DaytonaFields` structs** were introduced inside `details.rs` to hold the shared normalization output. Both `map_docker_inspect` (run-scoped) and `docker_info_from_inspect` (inventory) now delegate to `docker_fields_from_inspect`, eliminating duplicate field-extraction logic. Same pattern for Daytona. **`futures` moved from optional to unconditional** in `fabro-sandbox/Cargo.toml` because `join_all` / `try_join_all` are now used in `provider.rs`, which is not feature-gated. **`local` provider** intentionally returns an empty list and `None` for get — it has no provider-managed inventory. ### Fabro Details
Ran 8 stages in 102m 54s for $41.81 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 1s | – | 0 | | preflight_compile | 2m 6s | – | 0 | | preflight_lint | 2m 20s | – | 0 | | implement | 56m 40s | $10.82 | 0 | | simplify_opus | 27m 50s | $26.24 | 0 | | simplify_gpt | 5m 2s | $4.76 | 0 | | verify | 8m 24s | – | 0 | | **Total** | **102m 54s** | **$41.81** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-7; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> exit [condition="outcome=succeeded"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- docs/public/api-reference/fabro-api.yaml | 160 +++++++- lib/crates/fabro-api/build.rs | 18 +- lib/crates/fabro-api/src/lib.rs | 13 +- .../fabro-api/tests/run_sandbox_round_trip.rs | 8 +- .../tests/sandbox_details_round_trip.rs | 15 +- .../tests/sandbox_inventory_round_trip.rs | 123 ++++++ .../src/commands/run/run_progress/mod.rs | 6 +- lib/crates/fabro-dump/src/lib.rs | 8 +- lib/crates/fabro-install/src/lib.rs | 8 +- lib/crates/fabro-sandbox/Cargo.toml | 5 +- lib/crates/fabro-sandbox/src/daytona/mod.rs | 2 +- lib/crates/fabro-sandbox/src/details.rs | 227 +++++++++-- lib/crates/fabro-sandbox/src/lib.rs | 11 +- .../fabro-sandbox/src/managed_labels.rs | 10 +- lib/crates/fabro-sandbox/src/provider.rs | 360 ++++++++++++++++++ .../fabro-sandbox/src/provider/daytona.rs | 172 +++++++++ .../fabro-sandbox/src/provider/docker.rs | 163 ++++++++ lib/crates/fabro-sandbox/src/reconnect.rs | 12 +- lib/crates/fabro-sandbox/src/sandbox_spec.rs | 19 +- lib/crates/fabro-sandbox/src/terminal.rs | 14 +- lib/crates/fabro-sandbox/src/test_support.rs | 100 +++++ lib/crates/fabro-server/src/run_manifest.rs | 61 +-- lib/crates/fabro-server/src/serve.rs | 1 + lib/crates/fabro-server/src/server.rs | 62 ++- .../fabro-server/src/server/handler/mod.rs | 2 + .../src/server/handler/sandbox.rs | 50 +-- .../src/server/handler/sandboxes.rs | 247 ++++++++++++ lib/crates/fabro-server/src/server/tests.rs | 18 +- lib/crates/fabro-server/src/test_support.rs | 12 + lib/crates/fabro-store/src/run_state.rs | 4 +- .../tests/serializable_projection.rs | 4 +- lib/crates/fabro-types/src/lib.rs | 6 +- lib/crates/fabro-types/src/run_event/infra.rs | 4 +- lib/crates/fabro-types/src/run_sandbox.rs | 4 +- lib/crates/fabro-types/src/sandbox_details.rs | 4 +- .../fabro-types/src/sandbox_inventory.rs | 52 +++ .../fabro-types/src/sandbox_provider.rs | 34 +- lib/crates/fabro-types/src/settings/run.rs | 2 +- lib/crates/fabro-types/src/settings/server.rs | 11 +- .../tests/sandbox_inventory_serde.rs | 119 ++++++ .../fabro-types/tests/sandbox_model_serde.rs | 20 +- lib/crates/fabro-workflow/src/event/events.rs | 4 +- .../fabro-workflow/src/operations/retry.rs | 2 +- .../fabro-workflow/src/operations/start.rs | 14 +- .../fabro-workflow/tests/it/cp_integration.rs | 6 +- .../tests/it/daytona_integration.rs | 4 +- .../src/.openapi-generator/FILES | 7 +- lib/packages/fabro-api-client/src/api.ts | 1 + .../fabro-api-client/src/api/sandboxes-api.ts | 200 ++++++++++ .../src/models/delete-run-sandbox.ts | 4 +- .../fabro-api-client/src/models/index.ts | 6 +- .../src/models/run-sandbox.ts | 4 +- .../src/models/sandbox-info.ts | 77 ++++ .../src/models/sandbox-list-meta.ts | 25 ++ .../src/models/sandbox-list-response.ts | 29 ++ .../src/models/sandbox-provider-kind.ts | 27 ++ .../models/sandbox-provider-lookup-error.ts | 26 ++ 57 files changed, 2379 insertions(+), 228 deletions(-) create mode 100644 lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs create mode 100644 lib/crates/fabro-sandbox/src/provider.rs create mode 100644 lib/crates/fabro-sandbox/src/provider/daytona.rs create mode 100644 lib/crates/fabro-sandbox/src/provider/docker.rs create mode 100644 lib/crates/fabro-server/src/server/handler/sandboxes.rs create mode 100644 lib/crates/fabro-types/src/sandbox_inventory.rs create mode 100644 lib/crates/fabro-types/tests/sandbox_inventory_serde.rs create mode 100644 lib/packages/fabro-api-client/src/api/sandboxes-api.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-info.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-list-meta.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-list-response.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-provider-lookup-error.ts diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index fdada6ce5..6d190e0cf 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -15,6 +15,8 @@ tags: description: Browser authentication - name: Runs description: Run management operations + - name: Sandboxes + description: Provider-backed sandbox inventory - name: Sessions description: Ask Fabro sessions bound to runs - name: Human-in-the-Loop @@ -3632,6 +3634,67 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/sandboxes: + get: + operationId: listSandboxes + tags: [Sandboxes] + summary: List Sandboxes + description: Lists Fabro-managed sandboxes directly from configured sandbox providers. + responses: + "200": + description: Provider-backed sandbox inventory + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxListResponse" + + /api/v1/sandboxes/{id}: + get: + operationId: retrieveSandbox + tags: [Sandboxes] + summary: Retrieve Sandbox + description: Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers. + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + description: Sandbox found + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxInfo" + "404": + description: No provider found a Fabro-managed sandbox with this id + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: More than one provider matched this sandbox id + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "502": + description: Provider lookup failed before a definitive result could be determined + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/sandbox: get: operationId: retrieveRunSandbox @@ -7536,7 +7599,7 @@ components: required: [provider, id] properties: provider: - $ref: "#/components/schemas/SandboxProvider" + $ref: "#/components/schemas/SandboxProviderKind" id: type: string @@ -10177,8 +10240,8 @@ components: description: Equals `inference_time_ms + tool_time_ms`. example: 180000 - SandboxProvider: - description: Sandbox execution provider. + SandboxProviderKind: + description: Sandbox provider discriminator. type: string enum: - local @@ -10195,7 +10258,7 @@ components: - runtime properties: provider: - $ref: "#/components/schemas/SandboxProvider" + $ref: "#/components/schemas/SandboxProviderKind" image: type: ["string", "null"] snapshot: @@ -11097,6 +11160,95 @@ components: timestamps: $ref: "#/components/schemas/SandboxTimestamps" + SandboxInfo: + description: Provider-backed inventory record for a Fabro-managed sandbox. + type: object + required: + - provider + - id + - state + - resources + - network + - labels + - timestamps + properties: + provider: + $ref: "#/components/schemas/SandboxProviderKind" + id: + type: string + description: Provider-native sandbox id. + display_name: + type: ["string", "null"] + description: Provider display name when distinct from the native id. + state: + $ref: "#/components/schemas/SandboxState" + native_state: + type: ["string", "null"] + description: Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`. + image: + type: ["string", "null"] + description: Provider image when surfaced by the sandbox provider. + snapshot: + type: ["string", "null"] + description: Provider snapshot when surfaced by the sandbox provider. + region: + type: ["string", "null"] + description: Provider region or target. Null for local-style providers. + web_url: + type: ["string", "null"] + description: Provider dashboard URL for this sandbox when available. + working_directory: + type: ["string", "null"] + description: Provider-reported or Fabro-default working directory when available. + resources: + $ref: "#/components/schemas/SandboxResources" + network: + $ref: "#/components/schemas/SandboxNetwork" + labels: + type: object + additionalProperties: + type: string + description: Provider-reported labels. + timestamps: + $ref: "#/components/schemas/SandboxTimestamps" + + SandboxProviderLookupError: + description: Provider error captured during fail-soft sandbox inventory lookup. + type: object + required: + - provider + - message + properties: + provider: + $ref: "#/components/schemas/SandboxProviderKind" + message: + type: string + + SandboxListMeta: + description: Metadata for provider-backed sandbox inventory. + type: object + required: + - provider_errors + properties: + provider_errors: + type: array + items: + $ref: "#/components/schemas/SandboxProviderLookupError" + + SandboxListResponse: + description: Non-paginated provider-backed sandbox inventory response. + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: "#/components/schemas/SandboxInfo" + meta: + $ref: "#/components/schemas/SandboxListMeta" + SandboxFileEntry: description: A directory entry in a run sandbox. type: object diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 14d13d70e..737ae9cf9 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -530,9 +530,25 @@ fn main() { ("PreRunPushOutcome", "fabro_types::PreRunPushOutcome", &[]), ("DirtyStatus", "fabro_types::DirtyStatus", &[]), ("GitContext", "fabro_types::GitContext", &[]), - ("SandboxProvider", "fabro_types::SandboxProvider", &[]), + ( + "SandboxProviderKind", + "fabro_types::SandboxProviderKind", + &[], + ), ("RunSandbox", "fabro_types::RunSandbox", &[]), ("SandboxDetails", "fabro_types::SandboxDetails", &[]), + ("SandboxInfo", "fabro_types::SandboxInfo", &[]), + ( + "SandboxProviderLookupError", + "fabro_types::SandboxProviderLookupError", + &[], + ), + ("SandboxListMeta", "fabro_types::SandboxListMeta", &[]), + ( + "SandboxListResponse", + "fabro_types::SandboxListResponse", + &[], + ), ("SandboxNetwork", "fabro_types::SandboxNetwork", &[]), ( "SandboxNetworkPolicy", diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 9b40a152c..0eceb6ec3 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -47,12 +47,13 @@ pub mod types { QuestionType, RepositoryRef, Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox, - RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxNetwork, - SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, - SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps, - SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, SessionMessage, - SessionRecord, SessionStatus, SessionSummary, SessionTurn, SkillsProjection, - StageCompletion, StageContextWindow, StageContextWindowBreakdownItem, + RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxInfo, + SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, + SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError, + SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, + SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, + SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, + SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageProjection, diff --git a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs index 0e3a21af9..5c7da541a 100644 --- a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs @@ -1,19 +1,19 @@ use std::any::{TypeId, type_name}; -use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProvider as ApiSandboxProvider}; -use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider}; +use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProviderKind as ApiSandboxProvider}; +use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind}; use serde_json::json; #[test] fn run_sandbox_reuses_domain_types() { assert_same_type::(); - assert_same_type::(); + assert_same_type::(); } #[test] fn run_sandbox_json_matches_openapi_shape() { let sandbox = RunSandbox { - provider: SandboxProvider::Docker, + provider: SandboxProviderKind::Docker, image: Some("ghcr.io/fabro/sandbox:latest".to_string()), snapshot: None, runtime: Some(RunSandboxRuntime { diff --git a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs index 97485b4c1..a485ac4eb 100644 --- a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs +++ b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs @@ -5,20 +5,21 @@ use chrono::{TimeZone, Utc}; use fabro_api::types::{ SandboxDetails as ApiSandboxDetails, SandboxNetwork as ApiSandboxNetwork, SandboxNetworkPolicy as ApiSandboxNetworkPolicy, - SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode, SandboxProvider as ApiSandboxProvider, - SandboxResources as ApiSandboxResources, SandboxState as ApiSandboxState, - SandboxTimestamps as ApiSandboxTimestamps, + SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode, + SandboxProviderKind as ApiSandboxProvider, SandboxResources as ApiSandboxResources, + SandboxState as ApiSandboxState, SandboxTimestamps as ApiSandboxTimestamps, }; use fabro_types::{ RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, - SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, SandboxState, SandboxTimestamps, + SandboxNetworkPolicyMode, SandboxProviderKind, SandboxResources, SandboxState, + SandboxTimestamps, }; use serde_json::json; #[test] fn sandbox_details_reuses_domain_types() { assert_same_type::(); - assert_same_type::(); + assert_same_type::(); assert_same_type::(); assert_same_type::(); assert_same_type::(); @@ -32,7 +33,7 @@ fn sandbox_details_json_matches_openapi_shape() { let created_at = Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap(); let details = SandboxDetails { sandbox: RunSandbox { - provider: SandboxProvider::Docker, + provider: SandboxProviderKind::Docker, image: Some("ghcr.io/fabro/sandbox:latest".to_string()), snapshot: None, runtime: Some(RunSandboxRuntime { @@ -129,7 +130,7 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() { })) .unwrap(); - assert_eq!(details.sandbox.provider, SandboxProvider::Local); + assert_eq!(details.sandbox.provider, SandboxProviderKind::Local); assert_eq!( details .sandbox diff --git a/lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs new file mode 100644 index 000000000..85f6de00a --- /dev/null +++ b/lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs @@ -0,0 +1,123 @@ +use std::any::{TypeId, type_name}; +use std::collections::BTreeMap; + +use chrono::{TimeZone, Utc}; +use fabro_api::types::{ + SandboxInfo as ApiSandboxInfo, SandboxListMeta as ApiSandboxListMeta, + SandboxListResponse as ApiSandboxListResponse, SandboxProviderKind as ApiSandboxProviderKind, + SandboxProviderLookupError as ApiSandboxProviderLookupError, +}; +use fabro_types::{ + SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, + SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxState, + SandboxTimestamps, +}; +use serde_json::json; + +#[test] +fn sandbox_inventory_round_trip_reuses_domain_types() { + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[test] +fn sandbox_inventory_round_trip_json_matches_openapi_shape() { + let created_at = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(); + let response = SandboxListResponse { + data: vec![SandboxInfo { + provider: SandboxProviderKind::Daytona, + id: "sandbox-abc123".to_string(), + display_name: Some("fabro-01KSGHGMCFM8W2FHXNMJ7MVY65".to_string()), + state: SandboxState::Running, + native_state: Some("started".to_string()), + image: None, + snapshot: Some("daytona-medium".to_string()), + region: Some("us".to_string()), + web_url: Some( + "https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123".to_string(), + ), + working_directory: Some("/home/daytona/workspace".to_string()), + resources: SandboxResources { + cpu_cores: Some(2.0), + memory_bytes: Some(4 * 1024 * 1024 * 1024), + disk_bytes: Some(20 * 1024 * 1024 * 1024), + }, + network: SandboxNetwork { + egress: SandboxNetworkPolicy::open(), + ingress: SandboxNetworkPolicy::blocked(), + }, + labels: BTreeMap::from([( + "sh.fabro.managed".to_string(), + "true".to_string(), + )]), + timestamps: SandboxTimestamps { + created_at: Some(created_at), + last_activity_at: Some(created_at), + }, + }], + meta: SandboxListMeta { + provider_errors: vec![SandboxProviderLookupError { + provider: SandboxProviderKind::Docker, + message: "Failed to connect to Docker daemon".to_string(), + }], + }, + }; + + assert_eq!( + serde_json::to_value(&response).unwrap(), + json!({ + "data": [{ + "provider": "daytona", + "id": "sandbox-abc123", + "display_name": "fabro-01KSGHGMCFM8W2FHXNMJ7MVY65", + "state": "running", + "native_state": "started", + "snapshot": "daytona-medium", + "region": "us", + "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123", + "working_directory": "/home/daytona/workspace", + "resources": { + "cpu_cores": 2.0, + "memory_bytes": 4_294_967_296_u64, + "disk_bytes": 21_474_836_480_u64 + }, + "network": { + "egress": { + "mode": "open", + "cidrs": [] + }, + "ingress": { + "mode": "blocked", + "cidrs": [] + } + }, + "labels": { + "sh.fabro.managed": "true" + }, + "timestamps": { + "created_at": "2026-05-25T12:00:00Z", + "last_activity_at": "2026-05-25T12:00:00Z" + } + }], + "meta": { + "provider_errors": [{ + "provider": "docker", + "message": "Failed to connect to Docker daemon" + }] + } + }) + ); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index b13f34dea..782d2badf 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -474,7 +474,7 @@ mod tests { use fabro_model::{Catalog, ModelRef, ProviderId}; use fabro_types::run_event::CliEnsureCompletedProps; use fabro_types::{ - MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProvider, + MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProviderKind, StageId, fixtures, }; use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}; @@ -737,7 +737,7 @@ mod tests { stage_started("code", "Code"), Event::SandboxInitialized { working_directory: "/home/daytona/workspace".into(), - provider: SandboxProvider::Daytona, + provider: SandboxProviderKind::Daytona, id: "daytona:sandbox-id".into(), repo_cloned: None, clone_origin_url: None, @@ -1119,7 +1119,7 @@ mod tests { emit(&mut ui, stage_started("code", "Code")); emit(&mut ui, Event::SandboxInitialized { working_directory: "/home/daytona/workspace".into(), - provider: SandboxProvider::Daytona, + provider: SandboxProviderKind::Daytona, id: "daytona:sandbox-id".into(), repo_cloned: None, clone_origin_url: None, diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs index 3c7b5a070..d44e01215 100644 --- a/lib/crates/fabro-dump/src/lib.rs +++ b/lib/crates/fabro-dump/src/lib.rs @@ -473,9 +473,9 @@ mod tests { use fabro_types::graph::Graph; use fabro_types::run::RunSpec; use fabro_types::{ - Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus, SandboxProvider, - StageCompletion, StageModelUsage, StageOutcome, StartRecord, SuccessReason, - WorkflowSettings, first_event_seq, fixtures, + Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus, + SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord, + SuccessReason, WorkflowSettings, first_event_seq, fixtures, }; use futures::executor; @@ -558,7 +558,7 @@ mod tests { diff: RunDiff::default(), }); projection.sandbox = Some(RunSandbox { - provider: SandboxProvider::Local, + provider: SandboxProviderKind::Local, image: None, snapshot: None, runtime: Some(fabro_types::RunSandboxRuntime { diff --git a/lib/crates/fabro-install/src/lib.rs b/lib/crates/fabro-install/src/lib.rs index 933c161cc..7d88fe1dc 100644 --- a/lib/crates/fabro-install/src/lib.rs +++ b/lib/crates/fabro-install/src/lib.rs @@ -461,13 +461,13 @@ pub fn write_object_store_settings( } fn write_sandbox_provider_policy(server: &mut toml::Table) -> Result<()> { - use fabro_types::SandboxProvider; + use fabro_types::SandboxProviderKind; let sandbox = ensure_table(server, "sandbox")?; let providers = ensure_table(sandbox, "providers")?; for provider in [ - SandboxProvider::Local, - SandboxProvider::Docker, - SandboxProvider::Daytona, + SandboxProviderKind::Local, + SandboxProviderKind::Docker, + SandboxProviderKind::Daytona, ] { let entry = ensure_table(providers, &provider.to_string())?; entry.insert("enabled".to_string(), toml::Value::Boolean(true)); diff --git a/lib/crates/fabro-sandbox/Cargo.toml b/lib/crates/fabro-sandbox/Cargo.toml index 0f6c3f874..cdb6aad9d 100644 --- a/lib/crates/fabro-sandbox/Cargo.toml +++ b/lib/crates/fabro-sandbox/Cargo.toml @@ -9,7 +9,7 @@ description = "Sandbox trait and implementations for Fabro agent execution envir [features] default = ["local"] local = [] -docker = ["dep:bollard", "dep:tar", "dep:futures", "dep:fabro-github"] +docker = ["dep:bollard", "dep:tar", "dep:fabro-github"] daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-github", "dep:fabro-config", "dep:fabro-http", "dep:reqwest-middleware", "dep:rand", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls"] test-support = [] @@ -39,10 +39,11 @@ shlex = "1" # local glob = { version = "0.3" } +futures = { workspace = true } + # docker bollard = { workspace = true, optional = true } tar = { workspace = true, optional = true } -futures = { workspace = true, optional = true } # daytona fabro-config = { path = "../fabro-config", optional = true } diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 9609c4000..3f7faceca 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -128,7 +128,7 @@ async fn build_daytona_client( build_daytona_client_with(api_key, None, None, None).await } -async fn build_daytona_client_with( +pub(crate) async fn build_daytona_client_with( api_key: Option, api_url: Option, organization_id: Option, diff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/crates/fabro-sandbox/src/details.rs index 1aa057802..e6b65bd9b 100644 --- a/lib/crates/fabro-sandbox/src/details.rs +++ b/lib/crates/fabro-sandbox/src/details.rs @@ -4,7 +4,7 @@ use anyhow::Result; #[cfg(any(feature = "docker", feature = "daytona"))] use chrono::{DateTime, Utc}; use fabro_types::{ - RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProvider, SandboxResources, + RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, }; @@ -26,18 +26,18 @@ pub async fn sandbox_details( run_id: Option, ) -> Result { match record.provider { - SandboxProvider::Local => Ok(local_details(record)), + SandboxProviderKind::Local => Ok(local_details(record)), #[cfg(feature = "docker")] - SandboxProvider::Docker => docker::docker_details(record, run_id).await, + SandboxProviderKind::Docker => docker::docker_details(record, run_id).await, #[cfg(not(feature = "docker"))] - SandboxProvider::Docker => Err(anyhow::anyhow!( + SandboxProviderKind::Docker => Err(anyhow::anyhow!( "Sandbox provider '{}' has no details implementation", record.provider )), #[cfg(feature = "daytona")] - SandboxProvider::Daytona => daytona::daytona_details(record, daytona_api_key).await, + SandboxProviderKind::Daytona => daytona::daytona_details(record, daytona_api_key).await, #[cfg(not(feature = "daytona"))] - SandboxProvider::Daytona => Err(anyhow::anyhow!( + SandboxProviderKind::Daytona => Err(anyhow::anyhow!( "Sandbox provider '{}' has no details implementation", record.provider )), @@ -66,7 +66,7 @@ fn parse_rfc3339_utc(value: &str) -> Option> { } #[cfg(feature = "docker")] -mod docker { +pub(crate) mod docker { use std::collections::BTreeMap; use anyhow::{Context, Result, anyhow}; @@ -74,11 +74,12 @@ mod docker { use bollard::container::InspectContainerOptions; use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig}; use fabro_types::{ - RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources, - SandboxState, SandboxTimestamps, + RunId, RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, + SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, }; use super::parse_rfc3339_utc; + use crate::docker::WORKING_DIRECTORY; pub(super) async fn docker_details( record: &RunSandbox, @@ -94,13 +95,66 @@ mod docker { .inspect_container(&runtime.id, None::) .await .map_err(|err| anyhow!("Failed to inspect Docker container '{}': {err}", runtime.id))?; - Ok(map_docker_inspect(inspect, record)) + Ok(map_docker_inspect(&inspect, record)) } - fn map_docker_inspect( - inspect: ContainerInspectResponse, + pub(crate) fn docker_info_from_inspect(inspect: &ContainerInspectResponse) -> SandboxInfo { + let fields = docker_fields_from_inspect(inspect); + SandboxInfo { + provider: SandboxProviderKind::Docker, + id: fields.id, + display_name: fields.display_name, + state: fields.state, + native_state: fields.native_state, + image: fields.image, + snapshot: None, + region: None, + web_url: None, + working_directory: fields.working_directory, + resources: fields.resources, + network: fields.network, + labels: fields.labels, + timestamps: fields.timestamps, + } + } + + pub(super) fn map_docker_inspect( + inspect: &ContainerInspectResponse, record: &RunSandbox, ) -> SandboxDetails { + let fields = docker_fields_from_inspect(inspect); + let image = fields.image.clone().or_else(|| record.image.clone()); + + SandboxDetails { + sandbox: RunSandbox { + image, + ..record.clone() + }, + state: fields.state, + native_state: fields.native_state, + region: None, + web_url: None, + resources: fields.resources, + network: fields.network, + labels: fields.labels, + timestamps: fields.timestamps, + } + } + + struct DockerFields { + id: String, + display_name: Option, + state: SandboxState, + native_state: Option, + image: Option, + working_directory: Option, + resources: SandboxResources, + network: SandboxNetwork, + labels: BTreeMap, + timestamps: SandboxTimestamps, + } + + fn docker_fields_from_inspect(inspect: &ContainerInspectResponse) -> DockerFields { let status_enum = inspect .state .as_ref() @@ -124,23 +178,44 @@ mod docker { let labels: BTreeMap = inspect .config - .and_then(|config| config.labels) + .as_ref() + .and_then(|config| config.labels.clone()) .map(|map| map.into_iter().collect()) .unwrap_or_default(); - let image = inspect.image; + let image = inspect + .config + .as_ref() + .and_then(|config| config.image.clone()) + .or_else(|| inspect.image.clone()) + .filter(|value| !value.is_empty()); + let working_directory = inspect + .config + .as_ref() + .and_then(|config| config.working_dir.clone()) + .filter(|value| !value.is_empty()) + .or_else(|| Some(WORKING_DIRECTORY.to_string())); + + let id = inspect + .id + .clone() + .or_else(|| inspect.name.as_ref().map(|name| trim_container_name(name))) + .unwrap_or_default(); + let display_name = inspect + .name + .as_ref() + .map(|name| trim_container_name(name)) + .filter(|name| !name.is_empty()); let created_at = inspect.created.as_deref().and_then(parse_rfc3339_utc); - SandboxDetails { - sandbox: RunSandbox { - image: image.or_else(|| record.image.clone()), - ..record.clone() - }, + DockerFields { + id, + display_name, state: normalized_state, native_state, - region: None, - web_url: None, + image, + working_directory, resources, network, labels, @@ -151,6 +226,10 @@ mod docker { } } + fn trim_container_name(name: &str) -> String { + name.strip_prefix('/').unwrap_or(name).to_string() + } + fn docker_network(host_config: Option<&HostConfig>) -> SandboxNetwork { match host_config.and_then(|host| host.network_mode.as_deref()) { Some("none") => { @@ -195,14 +274,15 @@ mod docker { mod tests { use bollard::models::HostConfig; use fabro_types::{ - RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, SandboxProvider, + RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, + SandboxProviderKind, }; use super::*; fn record() -> RunSandbox { RunSandbox { - provider: SandboxProvider::Docker, + provider: SandboxProviderKind::Docker, image: None, snapshot: None, runtime: Some(RunSandboxRuntime { @@ -258,7 +338,7 @@ mod docker { }), ..Default::default() }; - let details = map_docker_inspect(inspect, &record()); + let details = map_docker_inspect(&inspect, &record()); assert_eq!(details.resources.memory_bytes, None); } @@ -271,7 +351,7 @@ mod docker { }), ..Default::default() }; - let details = map_docker_inspect(inspect, &record()); + let details = map_docker_inspect(&inspect, &record()); assert_eq!(details.resources.memory_bytes, Some(2_147_483_648)); } @@ -284,7 +364,7 @@ mod docker { }), ..Default::default() }; - let details = map_docker_inspect(inspect, &record()); + let details = map_docker_inspect(&inspect, &record()); assert_eq!(details.network.egress, SandboxNetworkPolicy::blocked()); assert_eq!(details.network.ingress, SandboxNetworkPolicy::blocked()); } @@ -298,7 +378,7 @@ mod docker { }), ..Default::default() }; - let details = map_docker_inspect(inspect, &record()); + let details = map_docker_inspect(&inspect, &record()); assert_eq!(details.network, SandboxNetwork::unknown()); } @@ -308,12 +388,31 @@ mod docker { name: Some("/fabro-run-abc".to_string()), ..Default::default() }; - let details = map_docker_inspect(inspect, &record()); + let details = map_docker_inspect(&inspect, &record()); let runtime = details.sandbox.runtime.expect("runtime"); assert_eq!(runtime.id, "container-abc123"); assert_eq!(runtime.working_directory, "/workspace"); } + #[test] + fn inventory_identity_uses_native_id_and_display_name() { + let inspect = ContainerInspectResponse { + id: Some("container-abc123".to_string()), + name: Some("/fabro-run-abc".to_string()), + ..Default::default() + }; + let info = docker_info_from_inspect(&inspect); + assert_eq!(info.id, "container-abc123"); + assert_eq!(info.display_name.as_deref(), Some("fabro-run-abc")); + } + + #[test] + fn inventory_working_directory_defaults_to_fabro_workspace() { + let inspect = ContainerInspectResponse::default(); + let info = docker_info_from_inspect(&inspect); + assert_eq!(info.working_directory.as_deref(), Some("/workspace")); + } + #[test] fn empty_status_is_unknown() { assert_eq!( @@ -392,18 +491,18 @@ mod docker { } #[cfg(feature = "daytona")] -mod daytona { +pub(crate) mod daytona { use std::collections::BTreeMap; use anyhow::{Context, Result, anyhow}; use daytona_api_client::models::SandboxState as DaytonaState; use fabro_types::{ - RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources, - SandboxState, SandboxTimestamps, + RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, + SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, }; use super::parse_rfc3339_utc; - use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox}; + use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox, WORKING_DIRECTORY}; pub(super) async fn daytona_details( record: &RunSandbox, @@ -434,7 +533,58 @@ mod daytona { Ok(map_daytona_sandbox(sdk_sandbox, record)) } - fn map_daytona_sandbox(sandbox: &daytona_sdk::Sandbox, record: &RunSandbox) -> SandboxDetails { + pub(crate) fn daytona_info_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> SandboxInfo { + let fields = daytona_fields_from_sdk_sandbox(sandbox); + SandboxInfo { + provider: SandboxProviderKind::Daytona, + id: sandbox.id.clone(), + display_name: Some(sandbox.name.clone()).filter(|name| !name.is_empty()), + state: fields.state, + native_state: fields.native_state, + image: None, + snapshot: sandbox.snapshot.clone(), + region: fields.region, + web_url: Some(daytona_dashboard_url(&sandbox.id)), + working_directory: Some(WORKING_DIRECTORY.to_string()), + resources: fields.resources, + network: fields.network, + labels: fields.labels, + timestamps: fields.timestamps, + } + } + + pub(super) fn map_daytona_sandbox( + sandbox: &daytona_sdk::Sandbox, + record: &RunSandbox, + ) -> SandboxDetails { + let fields = daytona_fields_from_sdk_sandbox(sandbox); + SandboxDetails { + sandbox: RunSandbox { + snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()), + ..record.clone() + }, + state: fields.state, + native_state: fields.native_state, + region: fields.region, + web_url: Some(daytona_dashboard_url(&sandbox.id)), + resources: fields.resources, + network: fields.network, + labels: fields.labels, + timestamps: fields.timestamps, + } + } + + struct DaytonaFields { + state: SandboxState, + native_state: Option, + region: Option, + resources: SandboxResources, + network: SandboxNetwork, + labels: BTreeMap, + timestamps: SandboxTimestamps, + } + + fn daytona_fields_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> DaytonaFields { let normalized_state = sandbox .state .map_or(SandboxState::Unknown, normalize_daytona_state); @@ -459,15 +609,10 @@ mod daytona { Some(target) }; - SandboxDetails { - sandbox: RunSandbox { - snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()), - ..record.clone() - }, + DaytonaFields { state: normalized_state, native_state, region, - web_url: Some(daytona_dashboard_url(&sandbox.id)), resources, network: daytona_network( sandbox.network_block_all, @@ -673,7 +818,7 @@ mod tests { #[test] fn local_details_returns_running_with_no_metadata() { let record = RunSandbox { - provider: SandboxProvider::Local, + provider: SandboxProviderKind::Local, image: None, snapshot: None, runtime: Some(fabro_types::RunSandboxRuntime { @@ -689,7 +834,7 @@ mod tests { }), }; let details = local_details(&record); - assert_eq!(details.sandbox.provider, SandboxProvider::Local); + assert_eq!(details.sandbox.provider, SandboxProviderKind::Local); assert_eq!(details.state, SandboxState::Running); let runtime = details.sandbox.runtime.as_ref().unwrap(); assert_eq!(runtime.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z"); diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index 3d3fe4a64..2fb0f0b4b 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -2,6 +2,7 @@ pub mod config; pub mod error; #[cfg(any(feature = "docker", feature = "daytona"))] pub mod from_environment; +pub mod provider; pub mod sandbox; pub mod sandbox_spec; @@ -39,8 +40,16 @@ pub use details::sandbox_details; #[cfg(feature = "docker")] pub use docker::{DockerSandbox, DockerSandboxOptions}; pub use error::{Error, Result, default_redacted_output_tail, display_for_log}; -pub use fabro_types::{RunSandbox, SandboxProvider}; +pub use fabro_types::{RunSandbox, SandboxProviderKind}; pub use local::LocalSandbox; +#[cfg(feature = "daytona")] +pub use provider::daytona::DaytonaSandboxProvider; +#[cfg(feature = "docker")] +pub use provider::docker::DockerSandboxProvider; +pub use provider::{ + LocalSandboxProvider, SandboxCreateSpec, SandboxLookupError, SandboxProvider, + SandboxProviderRegistry, +}; pub use read_guard::ReadBeforeWriteSandbox; pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callback}; pub use sandbox::{ diff --git a/lib/crates/fabro-sandbox/src/managed_labels.rs b/lib/crates/fabro-sandbox/src/managed_labels.rs index 2841105bc..09b2dd96f 100644 --- a/lib/crates/fabro-sandbox/src/managed_labels.rs +++ b/lib/crates/fabro-sandbox/src/managed_labels.rs @@ -3,8 +3,15 @@ use std::collections::HashMap; use fabro_types::RunId; pub(crate) const MANAGED_LABEL: &str = "sh.fabro.managed"; +pub(crate) const MANAGED_LABEL_VALUE: &str = "true"; pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id"; +/// True when the provided label map carries the Fabro managed sentinel. +#[cfg(any(feature = "docker", feature = "daytona", test))] +pub(crate) fn is_managed(labels: &HashMap) -> bool { + labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE) +} + #[cfg(any(feature = "docker", test))] pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap { let mut labels = HashMap::new(); @@ -23,7 +30,7 @@ pub(crate) fn merge_for_run( } fn insert_for_run(labels: &mut HashMap, run_id: Option<&RunId>) { - labels.insert(MANAGED_LABEL.to_string(), "true".to_string()); + labels.insert(MANAGED_LABEL.to_string(), MANAGED_LABEL_VALUE.to_string()); if let Some(run_id) = run_id { labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string()); } @@ -60,6 +67,7 @@ mod tests { labels.get(RUN_ID_LABEL).map(String::as_str), Some("01HY0000000000000000000000") ); + assert!(is_managed(&labels)); } #[test] diff --git a/lib/crates/fabro-sandbox/src/provider.rs b/lib/crates/fabro-sandbox/src/provider.rs new file mode 100644 index 000000000..b72d64efc --- /dev/null +++ b/lib/crates/fabro-sandbox/src/provider.rs @@ -0,0 +1,360 @@ +#[cfg(feature = "daytona")] +pub mod daytona; +#[cfg(feature = "docker")] +pub mod docker; + +use std::sync::Arc; + +use async_trait::async_trait; +#[cfg(any(feature = "docker", feature = "daytona"))] +use fabro_github::GitHubCredentials; +#[cfg(any(feature = "docker", feature = "daytona"))] +use fabro_types::RunId; +use fabro_types::{ + SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind, + SandboxProviderLookupError, +}; +use fabro_util::error::collect_chain; +use futures::future::join_all; + +#[cfg(feature = "daytona")] +use crate::daytona::DaytonaConfig; +#[cfg(feature = "docker")] +use crate::docker::DockerSandboxOptions; + +pub enum SandboxCreateSpec { + Local, + #[cfg(feature = "docker")] + Docker { + config: DockerSandboxOptions, + github_app: Option, + run_id: Option, + clone_origin_url: Option, + clone_branch: Option, + }, + #[cfg(feature = "daytona")] + Daytona { + config: Box, + github_app: Option, + run_id: Option, + clone_origin_url: Option, + clone_branch: Option, + api_key: Option, + }, +} + +#[async_trait] +pub trait SandboxProvider: Send + Sync { + fn kind(&self) -> SandboxProviderKind; + + async fn list(&self) -> crate::Result>; + async fn get(&self, id: &str) -> crate::Result>; + async fn create(&self, spec: SandboxCreateSpec) -> crate::Result; + async fn delete(&self, id: &str) -> crate::Result<()>; +} + +#[derive(Clone, Default)] +pub struct SandboxProviderRegistry { + providers: Vec>, +} + +impl SandboxProviderRegistry { + pub fn new(providers: Vec>) -> Self { + Self { providers } + } + + pub fn empty() -> Self { + Self::default() + } + + pub fn providers(&self) -> &[Arc] { + &self.providers + } + + pub async fn list_managed(&self) -> SandboxListResponse { + let results = join_all( + self.providers + .iter() + .map(|provider| async move { (provider.kind(), provider.list().await) }), + ) + .await; + + let mut data = Vec::new(); + let mut provider_errors = Vec::new(); + for (kind, result) in results { + match result { + Ok(mut sandboxes) => data.append(&mut sandboxes), + Err(err) => provider_errors.push(provider_error(kind, &err)), + } + } + + SandboxListResponse { + data, + meta: SandboxListMeta { provider_errors }, + } + } + + pub async fn get_managed_by_native_id( + &self, + id: &str, + ) -> Result { + let results = join_all( + self.providers + .iter() + .map(|provider| async move { (provider.kind(), provider.get(id).await) }), + ) + .await; + + let mut matches = Vec::new(); + let mut provider_errors = Vec::new(); + for (kind, result) in results { + match result { + Ok(Some(sandbox)) => matches.push(sandbox), + Ok(None) => {} + Err(err) => provider_errors.push(provider_error(kind, &err)), + } + } + + match matches.len() { + 1 => Ok(matches.remove(0)), + 0 if provider_errors.is_empty() => { + Err(SandboxLookupError::NotFound { id: id.to_string() }) + } + 0 => Err(SandboxLookupError::ProviderUnavailable { + id: id.to_string(), + provider_errors, + }), + _ => Err(SandboxLookupError::Conflict { + id: id.to_string(), + providers: matches + .into_iter() + .map(|sandbox| sandbox.provider) + .collect(), + }), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SandboxLookupError { + #[error("sandbox '{id}' was not found by any configured provider")] + NotFound { id: String }, + #[error("sandbox '{id}' matched more than one configured provider")] + Conflict { + id: String, + providers: Vec, + }, + #[error("sandbox '{id}' could not be found definitively because one or more providers failed")] + ProviderUnavailable { + id: String, + provider_errors: Vec, + }, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct LocalSandboxProvider; + +#[async_trait] +impl SandboxProvider for LocalSandboxProvider { + fn kind(&self) -> SandboxProviderKind { + SandboxProviderKind::Local + } + + async fn list(&self) -> crate::Result> { + Ok(Vec::new()) + } + + async fn get(&self, _id: &str) -> crate::Result> { + Ok(None) + } + + async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result { + Err(crate::Error::message( + "local sandbox provider has no provider-managed inventory", + )) + } + + async fn delete(&self, _id: &str) -> crate::Result<()> { + Ok(()) + } +} + +fn provider_error( + provider: SandboxProviderKind, + err: &(dyn std::error::Error + 'static), +) -> SandboxProviderLookupError { + SandboxProviderLookupError { + provider, + message: collect_chain(err).join(": "), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{ + FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info, + }; + + #[tokio::test] + async fn list_returns_aggregate_data_from_successful_providers() { + let docker = fake_sandbox_info(SandboxProviderKind::Docker, "docker-1"); + let daytona = fake_sandbox_info(SandboxProviderKind::Daytona, "daytona-1"); + let registry = fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(vec![docker.clone()]), + FakeGet::Missing, + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(vec![daytona.clone()]), + FakeGet::Missing, + ), + ]); + + let response = registry.list_managed().await; + + assert_eq!(response.data, vec![docker, daytona]); + assert!(response.meta.provider_errors.is_empty()); + } + + #[tokio::test] + async fn list_includes_provider_error_metadata_when_one_provider_fails() { + let docker = fake_sandbox_info(SandboxProviderKind::Docker, "docker-1"); + let registry = fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(vec![docker.clone()]), + FakeGet::Missing, + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Err("daytona unavailable"), + FakeGet::Missing, + ), + ]); + + let response = registry.list_managed().await; + + assert_eq!(response.data, vec![docker]); + assert_eq!(response.meta.provider_errors, vec![ + SandboxProviderLookupError { + provider: SandboxProviderKind::Daytona, + message: "daytona unavailable".to_string(), + } + ]); + } + + #[tokio::test] + async fn get_returns_one_matching_sandbox() { + let docker = fake_sandbox_info(SandboxProviderKind::Docker, "same-id"); + let registry = fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(Vec::new()), + FakeGet::Found(Box::new(docker.clone())), + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(Vec::new()), + FakeGet::Missing, + ), + ]); + + assert_eq!( + registry.get_managed_by_native_id("same-id").await.unwrap(), + docker + ); + } + + #[tokio::test] + async fn get_returns_not_found_when_all_providers_miss() { + let registry = fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(Vec::new()), + FakeGet::Missing, + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(Vec::new()), + FakeGet::Missing, + ), + ]); + + let err = registry + .get_managed_by_native_id("missing") + .await + .unwrap_err(); + + assert!(matches!(err, SandboxLookupError::NotFound { id } if id == "missing")); + } + + #[tokio::test] + async fn get_returns_conflict_when_two_providers_match() { + let registry = fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(Vec::new()), + FakeGet::Found(Box::new(fake_sandbox_info( + SandboxProviderKind::Docker, + "same-id", + ))), + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(Vec::new()), + FakeGet::Found(Box::new(fake_sandbox_info( + SandboxProviderKind::Daytona, + "same-id", + ))), + ), + ]); + + let err = registry + .get_managed_by_native_id("same-id") + .await + .unwrap_err(); + + assert!(matches!( + err, + SandboxLookupError::Conflict { id, providers } + if id == "same-id" + && providers == vec![SandboxProviderKind::Docker, SandboxProviderKind::Daytona] + )); + } + + #[tokio::test] + async fn get_returns_provider_unavailable_when_no_match_and_one_provider_fails() { + let registry = fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(Vec::new()), + FakeGet::Missing, + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(Vec::new()), + FakeGet::Err("daytona unavailable"), + ), + ]); + + let err = registry + .get_managed_by_native_id("maybe-missing") + .await + .unwrap_err(); + + assert!(matches!( + err, + SandboxLookupError::ProviderUnavailable { + id, + provider_errors + } if id == "maybe-missing" + && provider_errors == vec![SandboxProviderLookupError { + provider: SandboxProviderKind::Daytona, + message: "daytona unavailable".to_string(), + }] + )); + } +} diff --git a/lib/crates/fabro-sandbox/src/provider/daytona.rs b/lib/crates/fabro-sandbox/src/provider/daytona.rs new file mode 100644 index 000000000..7295afd97 --- /dev/null +++ b/lib/crates/fabro-sandbox/src/provider/daytona.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use daytona_sdk::DaytonaError; +use fabro_static::EnvVars; +use fabro_types::{SandboxInfo, SandboxProviderKind}; + +use super::{SandboxCreateSpec, SandboxProvider}; +use crate::daytona::{self, DaytonaSandbox}; +use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE}; +use crate::{Sandbox, details}; + +const DAYTONA_LIST_PAGE_SIZE: i32 = 100; + +#[derive(Clone)] +pub struct DaytonaSandboxProvider { + api_key: Option, + api_url: Option, + organization_id: Option, + http_client: Option, +} + +impl DaytonaSandboxProvider { + pub fn new( + api_key: Option, + api_url: Option, + organization_id: Option, + http_client: Option, + ) -> Self { + Self { + api_key, + api_url, + organization_id, + http_client, + } + } + + async fn client(&self) -> crate::Result { + let api_key = self.api_key.clone().ok_or_else(|| { + crate::Error::message(format!("{} is not configured", EnvVars::DAYTONA_API_KEY)) + })?; + daytona::build_daytona_client_with( + Some(api_key), + self.api_url.clone(), + self.organization_id.clone(), + self.http_client.clone(), + ) + .await + .map_err(|err| crate::Error::context("Failed to create Daytona client", err)) + } +} + +#[async_trait] +impl SandboxProvider for DaytonaSandboxProvider { + fn kind(&self) -> SandboxProviderKind { + SandboxProviderKind::Daytona + } + + async fn list(&self) -> crate::Result> { + let client = self.client().await?; + let labels = HashMap::from([(MANAGED_LABEL.to_string(), MANAGED_LABEL_VALUE.to_string())]); + let mut page = 1; + let mut sandboxes = Vec::new(); + + loop { + let result = client + .list(Some(&labels), Some(page), Some(DAYTONA_LIST_PAGE_SIZE)) + .await + .map_err(|err| crate::Error::context("Failed to list Daytona sandboxes", err))?; + // The Daytona API already filters by the managed label above; map every + // returned sandbox without re-checking the label client-side. + sandboxes.extend( + result + .items + .iter() + .map(details::daytona::daytona_info_from_sdk_sandbox), + ); + + if result.total_pages <= i64::from(page) { + break; + } + page += 1; + } + + Ok(sandboxes) + } + + async fn get(&self, id: &str) -> crate::Result> { + let client = self.client().await?; + let sandbox = match client.get(id).await { + Ok(sandbox) => sandbox, + Err(err) if daytona_not_found(&err) => return Ok(None), + Err(err) => { + return Err(crate::Error::context( + format!("Failed to get Daytona sandbox '{id}'"), + err, + )); + } + }; + + if !managed_from_sdk_sandbox(&sandbox) { + return Ok(None); + } + Ok(Some(details::daytona::daytona_info_from_sdk_sandbox( + &sandbox, + ))) + } + + async fn create(&self, spec: SandboxCreateSpec) -> crate::Result { + let SandboxCreateSpec::Daytona { + config, + github_app, + run_id, + clone_origin_url, + clone_branch, + api_key, + } = spec + else { + return Err(crate::Error::message( + "Daytona sandbox provider can only create Daytona sandboxes", + )); + }; + + let api_key = api_key.or_else(|| self.api_key.clone()).ok_or_else(|| { + crate::Error::message(format!("{} is not configured", EnvVars::DAYTONA_API_KEY)) + })?; + let sandbox = DaytonaSandbox::new( + config.as_ref().clone(), + github_app, + run_id, + clone_origin_url, + clone_branch, + Some(api_key), + ) + .await?; + sandbox.initialize().await?; + let sdk_sandbox = sandbox.sandbox_handle().ok_or_else(|| { + crate::Error::message("Daytona sandbox was created but no SDK handle is available") + })?; + Ok(details::daytona::daytona_info_from_sdk_sandbox(sdk_sandbox)) + } + + async fn delete(&self, id: &str) -> crate::Result<()> { + let client = self.client().await?; + let sandbox = match client.get(id).await { + Ok(sandbox) => sandbox, + Err(err) if daytona_not_found(&err) => return Ok(()), + Err(err) => { + return Err(crate::Error::context( + format!("Failed to get Daytona sandbox '{id}' before delete"), + err, + )); + } + }; + if !managed_from_sdk_sandbox(&sandbox) { + return Err(crate::Error::message(format!( + "Refusing to delete Daytona sandbox '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}" + ))); + } + client.delete(&sandbox.id).await.map_err(|err| { + crate::Error::context(format!("Failed to delete Daytona sandbox '{id}'"), err) + }) + } +} + +fn managed_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> bool { + managed_labels::is_managed(&sandbox.labels) +} + +fn daytona_not_found(err: &DaytonaError) -> bool { + matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404) +} diff --git a/lib/crates/fabro-sandbox/src/provider/docker.rs b/lib/crates/fabro-sandbox/src/provider/docker.rs new file mode 100644 index 000000000..13daf42c4 --- /dev/null +++ b/lib/crates/fabro-sandbox/src/provider/docker.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use bollard::Docker; +use bollard::container::{InspectContainerOptions, ListContainersOptions, RemoveContainerOptions}; +use bollard::errors::Error as DockerError; +use bollard::models::ContainerInspectResponse; +use fabro_types::{SandboxInfo, SandboxProviderKind}; +use futures::future::try_join_all; + +use super::{SandboxCreateSpec, SandboxProvider}; +use crate::docker::DockerSandbox; +use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE}; +use crate::{Sandbox, details}; + +#[derive(Debug, Clone, Default)] +pub struct DockerSandboxProvider; + +impl DockerSandboxProvider { + pub fn new() -> Self { + Self + } + + fn docker_client() -> crate::Result { + Docker::connect_with_local_defaults().map_err(crate::Error::docker_connect) + } +} + +#[async_trait] +impl SandboxProvider for DockerSandboxProvider { + fn kind(&self) -> SandboxProviderKind { + SandboxProviderKind::Docker + } + + async fn list(&self) -> crate::Result> { + let docker = Self::docker_client()?; + let mut filters = HashMap::new(); + filters.insert("label".to_string(), vec![format!( + "{MANAGED_LABEL}={MANAGED_LABEL_VALUE}" + )]); + let options = ListContainersOptions:: { + all: true, + filters, + ..Default::default() + }; + let containers = docker + .list_containers(Some(options)) + .await + .map_err(|err| crate::Error::context("Failed to list Docker containers", err))?; + + let ids: Vec = containers.into_iter().filter_map(|c| c.id).collect(); + // Daemon-side label filter already restricts to managed containers, so we + // can skip the per-inspect managed re-check. Run inspects concurrently on + // the shared Docker client to avoid a serial N+1 round-trip. + let inspects = try_join_all( + ids.iter() + .map(|id| docker.inspect_container(id, None::)), + ) + .await + .map_err(|err| crate::Error::context("Failed to inspect Docker container", err))?; + Ok(inspects + .iter() + .map(details::docker::docker_info_from_inspect) + .collect()) + } + + async fn get(&self, id: &str) -> crate::Result> { + let docker = Self::docker_client()?; + let Some(inspect) = inspect_container(&docker, id).await? else { + return Ok(None); + }; + if !managed_from_inspect(&inspect) { + return Ok(None); + } + Ok(Some(details::docker::docker_info_from_inspect(&inspect))) + } + + async fn create(&self, spec: SandboxCreateSpec) -> crate::Result { + let SandboxCreateSpec::Docker { + config, + github_app, + run_id, + clone_origin_url, + clone_branch, + } = spec + else { + return Err(crate::Error::message( + "Docker sandbox provider can only create Docker sandboxes", + )); + }; + + let sandbox = + DockerSandbox::new(config, github_app, run_id, clone_origin_url, clone_branch)?; + sandbox.initialize().await?; + let container_id = sandbox.container_identifier()?.to_string(); + self.get(&container_id).await?.ok_or_else(|| { + crate::Error::message(format!( + "Docker sandbox '{container_id}' was created but is not visible in provider inventory" + )) + }) + } + + async fn delete(&self, id: &str) -> crate::Result<()> { + let docker = Self::docker_client()?; + let Some(inspect) = inspect_container(&docker, id).await? else { + return Ok(()); + }; + if !managed_from_inspect(&inspect) { + return Err(crate::Error::message(format!( + "Refusing to delete Docker container '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}" + ))); + } + + let container_id = inspect.id.as_deref().unwrap_or(id); + docker + .remove_container( + container_id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await + .map_err(|err| { + crate::Error::context( + format!("Failed to remove Docker container '{container_id}'"), + err, + ) + }) + } +} + +async fn inspect_container( + docker: &Docker, + id: &str, +) -> crate::Result> { + match docker + .inspect_container(id, None::) + .await + { + Ok(inspect) => Ok(Some(inspect)), + Err(err) if docker_not_found(&err) => Ok(None), + Err(err) => Err(crate::Error::context( + format!("Failed to inspect Docker container '{id}'"), + err, + )), + } +} + +fn managed_from_inspect(inspect: &ContainerInspectResponse) -> bool { + inspect + .config + .as_ref() + .and_then(|config| config.labels.as_ref()) + .is_some_and(managed_labels::is_managed) +} + +fn docker_not_found(error: &DockerError) -> bool { + matches!(error, DockerError::DockerResponseServerError { + status_code: 404, + .. + }) +} diff --git a/lib/crates/fabro-sandbox/src/reconnect.rs b/lib/crates/fabro-sandbox/src/reconnect.rs index 1032f42fd..c71b6dc4e 100644 --- a/lib/crates/fabro-sandbox/src/reconnect.rs +++ b/lib/crates/fabro-sandbox/src/reconnect.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; reason = "Feature-gated branches consume these imports when optional backends are enabled." )] use anyhow::{Context, Result, bail}; -use fabro_types::{RunId, RunSandbox, SandboxProvider}; +use fabro_types::{RunId, RunSandbox, SandboxProviderKind}; use crate::SandboxEventCallback; #[cfg(feature = "daytona")] @@ -57,7 +57,7 @@ pub async fn reconnect_for_run_with_callback( .as_ref() .context("run sandbox missing runtime metadata")?; match record.provider { - SandboxProvider::Local => { + SandboxProviderKind::Local => { let mut sandbox = LocalSandbox::new(PathBuf::from(&runtime.working_directory)); if let Some(callback) = event_callback { sandbox.set_event_callback(callback); @@ -65,7 +65,7 @@ pub async fn reconnect_for_run_with_callback( Ok(Box::new(sandbox)) } #[cfg(feature = "docker")] - SandboxProvider::Docker => { + SandboxProviderKind::Docker => { let repo_cloned = runtime .repo_cloned .context("Docker run sandbox missing repo_cloned metadata")?; @@ -85,9 +85,9 @@ pub async fn reconnect_for_run_with_callback( Ok(Box::new(sandbox)) } #[cfg(not(feature = "docker"))] - SandboxProvider::Docker => bail!("Docker sandbox support is not enabled"), + SandboxProviderKind::Docker => bail!("Docker sandbox support is not enabled"), #[cfg(feature = "daytona")] - SandboxProvider::Daytona => { + SandboxProviderKind::Daytona => { let repo_cloned = runtime .repo_cloned .context("Daytona run sandbox missing repo_cloned metadata")?; @@ -108,6 +108,6 @@ pub async fn reconnect_for_run_with_callback( Ok(Box::new(sandbox)) } #[cfg(not(feature = "daytona"))] - SandboxProvider::Daytona => bail!("Daytona sandbox support is not enabled"), + SandboxProviderKind::Daytona => bail!("Daytona sandbox support is not enabled"), } } diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs index 377f94962..12c4bf000 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs @@ -9,7 +9,7 @@ use fabro_github::GitHubCredentials; unused_imports, reason = "Daytona-enabled builds persist RunId in the sandbox spec." )] -use fabro_types::{RunId, RunSandbox, RunSandboxRuntime, SandboxProvider}; +use fabro_types::{RunId, RunSandbox, RunSandboxRuntime, SandboxProviderKind}; #[cfg(any(feature = "docker", feature = "daytona"))] use crate::clone_source; @@ -45,21 +45,21 @@ pub enum SandboxSpec { } impl SandboxSpec { - pub fn provider(&self) -> SandboxProvider { + pub fn provider(&self) -> SandboxProviderKind { match self { - Self::Local { .. } => SandboxProvider::Local, + Self::Local { .. } => SandboxProviderKind::Local, #[cfg(feature = "docker")] - Self::Docker { .. } => SandboxProvider::Docker, + Self::Docker { .. } => SandboxProviderKind::Docker, #[cfg(feature = "daytona")] - Self::Daytona { .. } => SandboxProvider::Daytona, + Self::Daytona { .. } => SandboxProviderKind::Daytona, } } pub fn provider_name(&self) -> &'static str { match self.provider() { - SandboxProvider::Local => "local", - SandboxProvider::Docker => "docker", - SandboxProvider::Daytona => "daytona", + SandboxProviderKind::Local => "local", + SandboxProviderKind::Docker => "docker", + SandboxProviderKind::Daytona => "daytona", } } @@ -266,9 +266,12 @@ fn runtime_layout_metadata( #[cfg(test)] mod tests { + #[cfg(feature = "docker")] use fabro_types::RunId; + #[cfg(feature = "docker")] use super::*; + #[cfg(feature = "docker")] use crate::test_support::MockSandbox; #[cfg(feature = "docker")] diff --git a/lib/crates/fabro-sandbox/src/terminal.rs b/lib/crates/fabro-sandbox/src/terminal.rs index de31c9e45..67dfa29b6 100644 --- a/lib/crates/fabro-sandbox/src/terminal.rs +++ b/lib/crates/fabro-sandbox/src/terminal.rs @@ -1,15 +1,15 @@ use async_trait::async_trait; #[cfg(feature = "daytona")] use fabro_static::EnvVars; -use fabro_types::RunId; +use fabro_types::{RunId, SandboxProviderKind}; +use crate::RunSandbox; #[cfg(any(feature = "daytona", feature = "docker"))] use crate::Sandbox; #[cfg(feature = "daytona")] use crate::daytona::{DEFAULT_DAYTONA_API_URL, DaytonaSandbox}; #[cfg(feature = "docker")] use crate::docker::DockerSandbox; -use crate::{RunSandbox, SandboxProvider}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TerminalSize { @@ -55,7 +55,7 @@ pub async fn open_terminal_for_run( match record.provider { #[cfg(feature = "daytona")] - SandboxProvider::Daytona => { + SandboxProviderKind::Daytona => { let repo_cloned = runtime.repo_cloned.ok_or_else(|| { crate::Error::message("Daytona run sandbox is missing clone metadata") })?; @@ -82,11 +82,11 @@ pub async fn open_terminal_for_run( Ok(Box::new(session)) } #[cfg(not(feature = "daytona"))] - SandboxProvider::Daytona => Err(crate::Error::message( + SandboxProviderKind::Daytona => Err(crate::Error::message( "Daytona sandbox support is not enabled", )), #[cfg(feature = "docker")] - SandboxProvider::Docker => { + SandboxProviderKind::Docker => { let repo_cloned = runtime.repo_cloned.ok_or_else(|| { crate::Error::message("Docker run sandbox is missing clone metadata") })?; @@ -104,10 +104,10 @@ pub async fn open_terminal_for_run( Ok(Box::new(session)) } #[cfg(not(feature = "docker"))] - SandboxProvider::Docker => Err(crate::Error::message( + SandboxProviderKind::Docker => Err(crate::Error::message( "Docker sandbox support is not enabled", )), - SandboxProvider::Local => Err(crate::Error::message( + SandboxProviderKind::Local => Err(crate::Error::message( "Local sandboxes do not support embedded terminals", )), } diff --git a/lib/crates/fabro-sandbox/src/test_support.rs b/lib/crates/fabro-sandbox/src/test_support.rs index 6158aab23..1593c9032 100644 --- a/lib/crates/fabro-sandbox/src/test_support.rs +++ b/lib/crates/fabro-sandbox/src/test_support.rs @@ -560,3 +560,103 @@ impl Sandbox for MutableMockSandbox { "Linux 6.1.0".into() } } + +// --- FakeSandboxProvider --- + +pub use fake_provider::{FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info}; + +mod fake_provider { + use std::collections::BTreeMap; + use std::sync::Arc; + + use async_trait::async_trait; + use fabro_types::{ + SandboxInfo, SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, + SandboxTimestamps, + }; + + use crate::provider::{SandboxCreateSpec, SandboxProvider, SandboxProviderRegistry}; + + #[derive(Clone)] + pub enum FakeList { + Ok(Vec), + Err(&'static str), + } + + #[derive(Clone)] + pub enum FakeGet { + Found(Box), + Missing, + Err(&'static str), + } + + pub struct FakeSandboxProvider { + kind: SandboxProviderKind, + list: FakeList, + get: FakeGet, + } + + impl FakeSandboxProvider { + pub fn new(kind: SandboxProviderKind, list: FakeList, get: FakeGet) -> Self { + Self { kind, list, get } + } + } + + #[async_trait] + impl SandboxProvider for FakeSandboxProvider { + fn kind(&self) -> SandboxProviderKind { + self.kind + } + + async fn list(&self) -> crate::Result> { + match &self.list { + FakeList::Ok(sandboxes) => Ok(sandboxes.clone()), + FakeList::Err(message) => Err(crate::Error::message(*message)), + } + } + + async fn get(&self, _id: &str) -> crate::Result> { + match &self.get { + FakeGet::Found(sandbox) => Ok(Some((**sandbox).clone())), + FakeGet::Missing => Ok(None), + FakeGet::Err(message) => Err(crate::Error::message(*message)), + } + } + + async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result { + Err(crate::Error::message("not implemented")) + } + + async fn delete(&self, _id: &str) -> crate::Result<()> { + Ok(()) + } + } + + pub fn fake_registry(providers: Vec) -> SandboxProviderRegistry { + SandboxProviderRegistry::new( + providers + .into_iter() + .map(|provider| Arc::new(provider) as Arc) + .collect(), + ) + } + + pub fn fake_sandbox_info(provider: SandboxProviderKind, id: &str) -> SandboxInfo { + SandboxInfo { + provider, + id: id.to_string(), + display_name: None, + state: SandboxState::Running, + native_state: None, + image: None, + snapshot: None, + region: None, + web_url: None, + working_directory: None, + resources: SandboxResources::default(), + network: SandboxNetwork::unknown(), + labels: BTreeMap::new(), + timestamps: SandboxTimestamps::default(), + } + } +} diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index e59a79124..a004872cb 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -21,12 +21,12 @@ use fabro_sandbox::from_environment::{ daytona_config_from_environment, docker_config_from_environment, }; use fabro_sandbox::redact::redact_auth_url; -use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; +use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxSpec}; use fabro_static::EnvVars; use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{EnvironmentProvider, RunGoal, RunNamespace}; -use fabro_types::{ManifestPath, RunId, ServerSettings, WorkflowSettings}; +use fabro_types::{ManifestPath, RunId, SandboxProviderKind, ServerSettings, WorkflowSettings}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; use fabro_workflow::Error as WorkflowError; @@ -632,7 +632,7 @@ fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec Option { let enabled = server_settings .server @@ -647,8 +647,8 @@ pub(crate) fn sandbox_provider_policy_error( }) } -pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProvider { - SandboxProvider::from(settings.environment.provider).effective_for(settings.execution.mode) +pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProviderKind { + SandboxProviderKind::from(settings.environment.provider).effective_for(settings.execution.mode) } fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig { @@ -665,10 +665,10 @@ struct GitRemoteRefCheck { branch: Option, } -fn clone_disabled_for_provider(provider: SandboxProvider, resolved_run: &RunNamespace) -> bool { +fn clone_disabled_for_provider(provider: SandboxProviderKind, resolved_run: &RunNamespace) -> bool { match provider { - SandboxProvider::Docker | SandboxProvider::Daytona => !resolved_run.clone.enabled, - SandboxProvider::Local => false, + SandboxProviderKind::Docker | SandboxProviderKind::Daytona => !resolved_run.clone.enabled, + SandboxProviderKind::Local => false, } } @@ -742,7 +742,7 @@ fn repository_access_details(request: &GitRemoteRefCheck) -> Vec { async fn run_repository_access_check( checks: &mut Vec, - sandbox_provider: SandboxProvider, + sandbox_provider: SandboxProviderKind, prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, @@ -760,7 +760,7 @@ async fn run_repository_access_check( async fn run_repository_access_check_with( checks: &mut Vec, - sandbox_provider: SandboxProvider, + sandbox_provider: SandboxProviderKind, prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, @@ -876,7 +876,7 @@ async fn check_git_remote_ref( } fn preflight_sandbox_spec( - sandbox_provider: SandboxProvider, + sandbox_provider: SandboxProviderKind, prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, @@ -889,10 +889,10 @@ fn preflight_sandbox_spec( let clone_branch = prepared.git.as_ref().map(|git| git.branch.clone()); match sandbox_provider { - SandboxProvider::Local => SandboxSpec::Local { + SandboxProviderKind::Local => SandboxSpec::Local { working_directory: prepared.source_directory.clone(), }, - SandboxProvider::Docker => { + SandboxProviderKind::Docker => { let mut config = resolve_docker_config(resolved_run); config.skip_clone = true; SandboxSpec::Docker { @@ -903,7 +903,7 @@ fn preflight_sandbox_spec( clone_branch, } } - SandboxProvider::Daytona => { + SandboxProviderKind::Daytona => { let mut config = resolve_daytona_config(resolved_run); config.skip_clone = true; SandboxSpec::Daytona { @@ -920,7 +920,7 @@ fn preflight_sandbox_spec( async fn run_sandbox_check( checks: &mut Vec, - sandbox_provider: SandboxProvider, + sandbox_provider: SandboxProviderKind, prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, @@ -934,7 +934,7 @@ async fn run_sandbox_check( daytona_api_key, ); let sandbox_result: Result, String> = spec.build(None).await.map_err(|err| { - if matches!(sandbox_provider, SandboxProvider::Daytona) { + if matches!(sandbox_provider, SandboxProviderKind::Daytona) { format!("Daytona sandbox creation failed: {err}") } else { err.to_string() @@ -1468,7 +1468,7 @@ mod tests { } fn prepared_and_resolved_for_sandbox( - provider: SandboxProvider, + provider: SandboxProviderKind, clone_enabled: bool, git: Option, ) -> (PreparedManifest, RunNamespace) { @@ -1630,7 +1630,7 @@ dockerfile = { path = "Dockerfile" } #[tokio::test] async fn repository_access_check_skips_when_clone_is_disabled() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( - SandboxProvider::Docker, + SandboxProviderKind::Docker, false, Some(git_context("https://github.com/acme/widgets", "main")), ); @@ -1640,7 +1640,7 @@ dockerfile = { path = "Dockerfile" } let ok = run_repository_access_check_with( &mut checks, - SandboxProvider::Docker, + SandboxProviderKind::Docker, &prepared, &resolved, None, @@ -1659,7 +1659,7 @@ dockerfile = { path = "Dockerfile" } #[tokio::test] async fn repository_access_check_rejects_non_github_origins_before_remote_probe() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( - SandboxProvider::Docker, + SandboxProviderKind::Docker, true, Some(git_context("https://gitlab.com/acme/widgets", "main")), ); @@ -1669,7 +1669,7 @@ dockerfile = { path = "Dockerfile" } let ok = run_repository_access_check_with( &mut checks, - SandboxProvider::Docker, + SandboxProviderKind::Docker, &prepared, &resolved, None, @@ -1697,7 +1697,7 @@ dockerfile = { path = "Dockerfile" } #[tokio::test] async fn repository_access_check_probes_normalized_github_branch() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( - SandboxProvider::Docker, + SandboxProviderKind::Docker, true, Some(git_context( "git@github.com:acme/widgets.git", @@ -1710,7 +1710,7 @@ dockerfile = { path = "Dockerfile" } let ok = run_repository_access_check_with( &mut checks, - SandboxProvider::Docker, + SandboxProviderKind::Docker, &prepared, &resolved, None, @@ -1734,7 +1734,7 @@ dockerfile = { path = "Dockerfile" } #[tokio::test] async fn repository_access_check_surfaces_remote_probe_failure() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( - SandboxProvider::Docker, + SandboxProviderKind::Docker, true, Some(git_context("https://github.com/acme/widgets", "missing")), ); @@ -1742,7 +1742,7 @@ dockerfile = { path = "Dockerfile" } let ok = run_repository_access_check_with( &mut checks, - SandboxProvider::Docker, + SandboxProviderKind::Docker, &prepared, &resolved, None, @@ -1766,13 +1766,18 @@ dockerfile = { path = "Dockerfile" } #[test] fn preflight_sandbox_spec_disables_docker_clone_but_preserves_clone_metadata() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( - SandboxProvider::Docker, + SandboxProviderKind::Docker, true, Some(git_context("https://github.com/acme/widgets", "main")), ); - let spec = - preflight_sandbox_spec(SandboxProvider::Docker, &prepared, &resolved, None, None); + let spec = preflight_sandbox_spec( + SandboxProviderKind::Docker, + &prepared, + &resolved, + None, + None, + ); match spec { SandboxSpec::Docker { diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 39212138a..f274aadff 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -810,6 +810,7 @@ where github_api_base_url: None, active_config_path, http_client: None, + sandbox_provider_registry: None, shutdown: shutdown.clone(), })?; let reconciled = reconcile_incomplete_runs_on_startup(&state).await?; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index d224b9991..96a1dfcf6 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -65,7 +65,10 @@ use fabro_redact::redact_jsonl_line; use fabro_sandbox::daytona::{self, DaytonaSandbox}; use fabro_sandbox::details::sandbox_details; use fabro_sandbox::reconnect::reconnect_for_run; -use fabro_sandbox::{Sandbox, SandboxProvider}; +use fabro_sandbox::{ + DaytonaSandboxProvider, DockerSandboxProvider, LocalSandboxProvider, Sandbox, SandboxProvider, + SandboxProviderRegistry, +}; use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient}; use fabro_slack::config::{ SlackCredentialResolution, @@ -89,8 +92,8 @@ use fabro_types::settings::{InterpString, RunNamespace}; use fabro_types::{ AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId, PairMessageId, PairTarget, PendingReason, Principal, PullRequestLink, QuestionType, RunBlobId, - RunControlAction, RunEvent, RunId, RunRunnableSource, ServerSettings, SessionCapability, - StageModelUsage, + RunControlAction, RunEvent, RunId, RunRunnableSource, SandboxProviderKind, ServerSettings, + SessionCapability, StageModelUsage, }; use fabro_util::error::{ SharedError, collect_causes, render_compact_with_causes, render_with_causes, @@ -960,6 +963,7 @@ pub struct AppState { pub(crate) github_api_base_url: String, active_config_path: PathBuf, http_client: Option, + sandbox_provider_registry: SandboxProviderRegistry, shutdown: CancellationToken, shutting_down: AtomicBool, registry_factory_override: Option>, @@ -1060,6 +1064,7 @@ pub(crate) struct AppStateConfig { pub(crate) github_api_base_url: Option, pub(crate) active_config_path: PathBuf, pub(crate) http_client: Option, + pub(crate) sandbox_provider_registry: Option, pub(crate) shutdown: CancellationToken, } @@ -1260,6 +1265,10 @@ impl AppState { &self.session_runtimes } + pub(crate) fn sandbox_provider_registry(&self) -> &SandboxProviderRegistry { + &self.sandbox_provider_registry + } + pub(crate) fn server_secret(&self, name: &str) -> Option { self.server_secrets.get(name) } @@ -2002,7 +2011,7 @@ fn system_sandbox_provider( manifest_run_settings: &std::result::Result, ) -> String { manifest_run_settings.as_ref().map_or_else( - |_| SandboxProvider::default().to_string(), + |_| SandboxProviderKind::default().to_string(), |settings| settings.environment.provider.to_string(), ) } @@ -2050,6 +2059,38 @@ fn worker_token_keys_from_server_secrets( .map_err(|err| jwt_auth::session_secret_key_error(&err)) } +fn build_sandbox_provider_registry( + server_settings: &ServerSettings, + daytona_api_key: Option, + env_lookup: &EnvLookup, + http_client: Option, +) -> SandboxProviderRegistry { + let provider_settings = &server_settings.server.sandbox.providers; + let mut providers: Vec> = Vec::new(); + + if provider_settings.local.enabled { + providers.push(Arc::new(LocalSandboxProvider)); + } + + if provider_settings.docker.enabled { + providers.push(Arc::new(DockerSandboxProvider::new())); + } + + if provider_settings.daytona.enabled && daytona_api_key.is_some() { + let api_url = env_lookup(EnvVars::DAYTONA_API_URL) + .or_else(|| env_lookup(EnvVars::DAYTONA_SERVER_URL)); + let organization_id = env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID); + providers.push(Arc::new(DaytonaSandboxProvider::new( + daytona_api_key, + api_url, + organization_id, + http_client, + ))); + } + + SandboxProviderRegistry::new(providers) +} + pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result> { let AppStateConfig { resolved_settings, @@ -2064,6 +2105,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result vault, None => load_startup_vault(&vault_path)?, }; + // Read vault secrets needed for synchronous setup before we wrap the vault in + // an async lock for the rest of AppState. + let daytona_api_key = vault.get(EnvVars::DAYTONA_API_KEY).map(str::to_string); let vault = Arc::new(AsyncRwLock::new(vault)); let llm_source: Arc = Arc::new(VaultCredentialSource::vault_only(Arc::clone(&vault))); @@ -2084,6 +2129,14 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result Router> { .merge(pull_requests::routes()) .merge(artifacts::routes()) .merge(sandbox::routes()) + .merge(sandboxes::routes()) .merge(lifecycle::routes()) .merge(steer::routes()) .merge(pair::routes()) diff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/crates/fabro-server/src/server/handler/sandbox.rs index 458e36720..3bad166d4 100644 --- a/lib/crates/fabro-server/src/server/handler/sandbox.rs +++ b/lib/crates/fabro-server/src/server/handler/sandbox.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use fabro_sandbox::{TerminalSize, open_terminal_for_run}; -use fabro_types::{SandboxServiceDiscoverySource, SandboxServiceListMeta}; +use fabro_types::{SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta}; use futures_util::FutureExt; use futures_util::future::BoxFuture; @@ -13,10 +13,10 @@ use super::super::{ ApiError, AppState, Bytes, DaytonaSandbox, EnvVars, HeaderMap, IntoResponse, Json, NamedTempFile, Path, PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId, Sandbox, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, - SandboxProvider, SandboxService, SandboxServiceListResponse, SshAccessRequest, - SshAccessResponse, State, StatusCode, VncPreviewResponse, collect_causes, fs, get, - octet_stream_response, parse_run_id_path, post, reconnect_for_run, reject_if_archived, - render_with_causes, sandbox_details, + SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State, + StatusCode, VncPreviewResponse, collect_causes, fs, get, octet_stream_response, + parse_run_id_path, post, reconnect_for_run, reject_if_archived, render_with_causes, + sandbox_details, }; const MAX_TERMINAL_CONTROL_BYTES: usize = 4096; @@ -400,7 +400,7 @@ async fn create_ssh_access( }; match record.provider { - SandboxProvider::Daytona => { + SandboxProviderKind::Daytona => { let sandbox = match reconnect_daytona_sandbox(&state, &id).await { Ok(sandbox) => sandbox, Err(response) => return response, @@ -414,7 +414,7 @@ async fn create_ssh_access( } } } - SandboxProvider::Docker => { + SandboxProviderKind::Docker => { let sandbox = match reconnect_run_sandbox(&state, &id).await { Ok(sandbox) => sandbox, Err(response) => return response, @@ -433,7 +433,7 @@ async fn create_ssh_access( } } } - SandboxProvider::Local => ApiError::new( + SandboxProviderKind::Local => ApiError::new( StatusCode::CONFLICT, "Sandbox provider does not support access commands.", ) @@ -454,7 +454,7 @@ async fn create_sandbox_vnc_preview( Ok(record) => record, Err(response) => return response, }; - if record.provider != SandboxProvider::Daytona { + if record.provider != SandboxProviderKind::Daytona { return ApiError::new( StatusCode::NOT_IMPLEMENTED, "Sandbox provider does not support VNC previews.", @@ -613,7 +613,7 @@ struct SandboxServiceDiscovery { source: SandboxServiceDiscoverySource, } -fn parse_sandbox_services(output: &str, provider: SandboxProvider) -> SandboxServiceDiscovery { +fn parse_sandbox_services(output: &str, provider: SandboxProviderKind) -> SandboxServiceDiscovery { if output .lines() .any(|line| line.trim_start().starts_with("FABRO_PROC_NET_TCP ")) @@ -630,7 +630,7 @@ fn parse_sandbox_services(output: &str, provider: SandboxProvider) -> SandboxSer } } -fn parse_ss_listening_services(output: &str, provider: SandboxProvider) -> Vec { +fn parse_ss_listening_services(output: &str, provider: SandboxProviderKind) -> Vec { let mut services = BTreeMap::::new(); for line in output .lines() @@ -663,7 +663,7 @@ enum ProcNetFamily { fn parse_proc_net_listening_services( output: &str, - provider: SandboxProvider, + provider: SandboxProviderKind, ) -> Vec { let mut services = BTreeMap::::new(); let mut family = None; @@ -743,7 +743,7 @@ fn parse_proc_net_ipv6(value: &str) -> Option { fn push_service( services: &mut BTreeMap, - provider: SandboxProvider, + provider: SandboxProviderKind, port: u16, address: String, process: Option, @@ -760,8 +760,8 @@ fn push_service( } } -fn preview_supported(provider: SandboxProvider, port: u16) -> bool { - provider == SandboxProvider::Daytona && (3000..=9999).contains(&port) +fn preview_supported(provider: SandboxProviderKind, port: u16) -> bool { + provider == SandboxProviderKind::Daytona && (3000..=9999).contains(&port) } fn push_unique(values: &mut Vec, value: String) { @@ -866,7 +866,7 @@ async fn reconnect_daytona_sandbox( run_id: &RunId, ) -> Result { let record = load_run_sandbox(state, run_id).await?; - if record.provider != SandboxProvider::Daytona { + if record.provider != SandboxProviderKind::Daytona { return Err(ApiError::new( StatusCode::CONFLICT, "Sandbox provider does not support this capability.", @@ -1001,7 +1001,7 @@ LISTEN 0 4096 0.0.0.0:5173 0.0.0.0:* users:(("vite",pid=84,fd=19)) LISTEN 0 4096 [::]:8080 [::]:* users:(("server",pid=126,fd=9)) LISTEN 0 4096 [::1]:2500 [::]:* users:(("debug",pid=168,fd=7)) "#, - SandboxProvider::Daytona, + SandboxProviderKind::Daytona, ); assert_eq!(services.len(), 4); @@ -1035,7 +1035,7 @@ not enough fields LISTEN 0 4096 127.0.0.1:0 0.0.0.0:* users:(("zero",pid=1,fd=2)) LISTEN 0 4096 127.0.0.1:65536 0.0.0.0:* users:(("large",pid=1,fd=2)) "#, - SandboxProvider::Daytona, + SandboxProviderKind::Daytona, ); assert!(services.is_empty()); @@ -1050,7 +1050,7 @@ LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=42,fd=23)) LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23)) LISTEN 0 4096 [::]:3000 [::]:* users:(("vite",pid=84,fd=19)) "#, - SandboxProvider::Daytona, + SandboxProviderKind::Daytona, ); assert_eq!(services, vec![SandboxService { @@ -1082,7 +1082,7 @@ FABRO_PROC_NET_TCP /proc/net/tcp6 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 44444 1: 00000000000000000000000001000000:09C4 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 55555 ", - SandboxProvider::Daytona, + SandboxProviderKind::Daytona, ); assert_eq!(discovery.source, SandboxServiceDiscoverySource::Procfs); @@ -1116,11 +1116,11 @@ FABRO_PROC_NET_TCP /proc/net/tcp6 #[test] fn preview_support_is_daytona_only_for_documented_range() { - assert!(!preview_supported(SandboxProvider::Daytona, 2500)); - assert!(preview_supported(SandboxProvider::Daytona, 3000)); - assert!(preview_supported(SandboxProvider::Daytona, 9999)); - assert!(!preview_supported(SandboxProvider::Daytona, 10000)); - assert!(!preview_supported(SandboxProvider::Docker, 3000)); + assert!(!preview_supported(SandboxProviderKind::Daytona, 2500)); + assert!(preview_supported(SandboxProviderKind::Daytona, 3000)); + assert!(preview_supported(SandboxProviderKind::Daytona, 9999)); + assert!(!preview_supported(SandboxProviderKind::Daytona, 10000)); + assert!(!preview_supported(SandboxProviderKind::Docker, 3000)); } #[test] diff --git a/lib/crates/fabro-server/src/server/handler/sandboxes.rs b/lib/crates/fabro-server/src/server/handler/sandboxes.rs new file mode 100644 index 000000000..2d4cf9a90 --- /dev/null +++ b/lib/crates/fabro-server/src/server/handler/sandboxes.rs @@ -0,0 +1,247 @@ +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::get; +use axum::{Json, Router}; +use fabro_sandbox::SandboxLookupError; +use fabro_types::{SandboxInfo, SandboxListResponse, SandboxProviderKind}; + +use super::super::AppState; +use crate::error::ApiError; +use crate::principal_middleware::RequiredRunManagementActor; + +pub(super) fn routes() -> Router> { + Router::new() + .route("/sandboxes", get(list_sandboxes)) + .route("/sandboxes/{id}", get(retrieve_sandbox)) +} + +async fn list_sandboxes( + State(state): State>, + _auth: RequiredRunManagementActor, +) -> Json { + Json(state.sandbox_provider_registry().list_managed().await) +} + +async fn retrieve_sandbox( + State(state): State>, + Path(id): Path, + _auth: RequiredRunManagementActor, +) -> Result, ApiError> { + state + .sandbox_provider_registry() + .get_managed_by_native_id(&id) + .await + .map(Json) + .map_err(sandbox_lookup_error) +} + +fn sandbox_lookup_error(err: SandboxLookupError) -> ApiError { + match err { + SandboxLookupError::NotFound { id } => ApiError::new( + StatusCode::NOT_FOUND, + format!("No provider found a Fabro-managed sandbox with id '{id}'."), + ), + SandboxLookupError::Conflict { id, providers } => ApiError::new( + StatusCode::CONFLICT, + format!( + "More than one provider matched sandbox id '{id}': {}.", + provider_list(&providers) + ), + ), + SandboxLookupError::ProviderUnavailable { + id, + provider_errors, + } => ApiError::new( + StatusCode::BAD_GATEWAY, + format!( + "Provider lookup for sandbox id '{id}' failed before a definitive result could be determined: {}.", + provider_errors + .iter() + .map(|error| format!("{}: {}", error.provider, error.message)) + .collect::>() + .join("; ") + ), + ), + } +} + +fn provider_list(providers: &[SandboxProviderKind]) -> String { + providers + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode}; + use fabro_sandbox::SandboxProviderRegistry; + use fabro_sandbox::test_support::{ + FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info, + }; + use fabro_types::SandboxProviderKind; + use serde_json::{Value, json}; + use tower::ServiceExt; + + use crate::test_support::{TestAppStateBuilder, build_test_router}; + + fn app_with_registry(registry: SandboxProviderRegistry) -> axum::Router { + let state = TestAppStateBuilder::new() + .sandbox_provider_registry(registry) + .build(); + build_test_router(state) + } + + fn req_get(uri: &str) -> Request { + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty()) + .expect("sandbox inventory GET request should build") + } + + async fn body_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should fit in memory"); + serde_json::from_slice(&bytes).expect("response body should be valid JSON") + } + + #[tokio::test] + async fn list_returns_provider_backed_data_without_run_projection_state() { + let docker = fake_sandbox_info(SandboxProviderKind::Docker, "docker-native-id"); + let app = app_with_registry(fake_registry(vec![FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(vec![docker]), + FakeGet::Missing, + )])); + + let response = app.oneshot(req_get("/api/v1/sandboxes")).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert_eq!(body["data"][0]["id"], "docker-native-id"); + assert_eq!(body["data"][0]["provider"], "docker"); + assert_eq!(body["meta"]["provider_errors"], json!([])); + } + + #[tokio::test] + async fn retrieve_searches_all_configured_providers() { + let daytona = fake_sandbox_info(SandboxProviderKind::Daytona, "native-id"); + let app = app_with_registry(fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(Vec::new()), + FakeGet::Missing, + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(Vec::new()), + FakeGet::Found(Box::new(daytona)), + ), + ])); + + let response = app + .oneshot(req_get("/api/v1/sandboxes/native-id")) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert_eq!(body["id"], "native-id"); + assert_eq!(body["provider"], "daytona"); + } + + #[tokio::test] + async fn no_matching_sandbox_returns_404() { + let app = app_with_registry(fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(Vec::new()), + FakeGet::Missing, + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(Vec::new()), + FakeGet::Missing, + ), + ])); + + let response = app + .oneshot(req_get("/api/v1/sandboxes/missing")) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn duplicate_native_ids_return_409() { + let app = app_with_registry(fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(Vec::new()), + FakeGet::Found(Box::new(fake_sandbox_info( + SandboxProviderKind::Docker, + "same-id", + ))), + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(Vec::new()), + FakeGet::Found(Box::new(fake_sandbox_info( + SandboxProviderKind::Daytona, + "same-id", + ))), + ), + ])); + + let response = app + .oneshot(req_get("/api/v1/sandboxes/same-id")) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = body_json(response).await; + assert!( + body["errors"][0]["detail"] + .as_str() + .unwrap_or_default() + .contains("More than one provider matched") + ); + } + + #[tokio::test] + async fn provider_lookup_uncertainty_returns_502() { + let app = app_with_registry(fake_registry(vec![ + FakeSandboxProvider::new( + SandboxProviderKind::Docker, + FakeList::Ok(Vec::new()), + FakeGet::Missing, + ), + FakeSandboxProvider::new( + SandboxProviderKind::Daytona, + FakeList::Ok(Vec::new()), + FakeGet::Err("daytona unavailable"), + ), + ])); + + let response = app + .oneshot(req_get("/api/v1/sandboxes/maybe-missing")) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = body_json(response).await; + assert!( + body["errors"][0]["detail"] + .as_str() + .unwrap_or_default() + .contains("daytona unavailable") + ); + } +} diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 6d7b619f5..7da6682d3 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -21,7 +21,7 @@ use fabro_types::settings::ServerAuthMethod; use fabro_types::{ AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph, InterviewQuestionRecord, Node, Outcome, QuestionType, RunBlobId, RunId, RunSpec, - SandboxProvider, StageContextWindowBreakdownItem, StageContextWindowCategory, + SandboxProviderKind, StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning, StageModelUsage, StageTiming, SuccessReason, SystemActorKind, WorkflowSettings, fixtures, @@ -961,7 +961,7 @@ id = "missing" assert_eq!( system_sandbox_provider(&manifest_run_settings), - SandboxProvider::default().to_string() + SandboxProviderKind::default().to_string() ); } @@ -980,7 +980,7 @@ enabled = false ); assert_eq!( - crate::run_manifest::sandbox_provider_policy_error(&settings, SandboxProvider::Daytona) + crate::run_manifest::sandbox_provider_policy_error(&settings, SandboxProviderKind::Daytona) .as_deref(), Some( "sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled" @@ -1659,6 +1659,7 @@ fn slack_app_state_with_secret_sources( github_api_base_url: None, active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"), http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), + sandbox_provider_registry: None, shutdown: tokio_util::sync::CancellationToken::new(), }) .expect("slack test app state should build") @@ -1999,6 +2000,7 @@ methods = ["dev-token"] github_api_base_url: None, active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"), http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), + sandbox_provider_registry: None, shutdown: tokio_util::sync::CancellationToken::new(), }) else { panic!("build_app_state should require SESSION_SECRET") @@ -2124,6 +2126,7 @@ fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result, run_id: RunId definition_blob: None, }, workflow_event::Event::SandboxInitialized { - provider: SandboxProvider::Local, + provider: SandboxProviderKind::Local, id: "sandbox-preserve-1".to_string(), working_directory: "/tmp/fabro-preserved-sandbox".to_string(), repo_cloned: None, @@ -12326,7 +12330,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() { workflow_event::Event::RunStarting, workflow_event::Event::RunRunning, workflow_event::Event::SandboxInitialized { - provider: SandboxProvider::Docker, + provider: SandboxProviderKind::Docker, id: "missing-sandbox".to_string(), working_directory: "/tmp/fabro-missing-sandbox".to_string(), repo_cloned: Some(false), @@ -14220,7 +14224,7 @@ async fn list_runs_includes_live_metadata_from_run_state() { workflow_event::Event::RunStarting, workflow_event::Event::RunRunning, workflow_event::Event::SandboxInitialized { - provider: SandboxProvider::Local, + provider: SandboxProviderKind::Local, id: "sb-test".to_string(), working_directory: "/sandbox/workdir".to_string(), repo_cloned: None, @@ -14299,7 +14303,7 @@ async fn list_runs_page_limit_preserves_metadata_for_paged_items() { workflow_event::Event::RunStarting, workflow_event::Event::RunRunning, workflow_event::Event::SandboxInitialized { - provider: SandboxProvider::Local, + provider: SandboxProviderKind::Local, id: sandbox_id.to_string(), working_directory: "/sandbox/workdir".to_string(), repo_cloned: None, diff --git a/lib/crates/fabro-server/src/test_support.rs b/lib/crates/fabro-server/src/test_support.rs index b61f01df8..b4e2c9df9 100644 --- a/lib/crates/fabro-server/src/test_support.rs +++ b/lib/crates/fabro-server/src/test_support.rs @@ -16,6 +16,7 @@ use chrono::Duration as ChronoDuration; use fabro_config::{RunLayer, RunSettingsBuilder, ServerSettingsBuilder, envfile}; use fabro_interview::Interviewer; use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; +use fabro_sandbox::SandboxProviderRegistry; use fabro_static::EnvVars; use fabro_store::{ArtifactStore, Database}; use fabro_types::settings::ServerAuthMethod; @@ -61,6 +62,7 @@ pub struct TestAppStateBuilder { manifest_run_defaults: RunLayer, max_concurrent_runs: usize, registry_factory_override: Option>, + sandbox_provider_registry: Option, store_bundle: Option<(Arc, ArtifactStore)>, vault_path: Option, vault_entries: Vec<(String, String)>, @@ -78,6 +80,7 @@ impl Default for TestAppStateBuilder { manifest_run_defaults: RunLayer::default(), max_concurrent_runs: 5, registry_factory_override: None, + sandbox_provider_registry: None, store_bundle: None, vault_path: None, vault_entries: Vec::new(), @@ -121,6 +124,14 @@ impl TestAppStateBuilder { self } + pub fn sandbox_provider_registry( + mut self, + sandbox_provider_registry: SandboxProviderRegistry, + ) -> Self { + self.sandbox_provider_registry = Some(sandbox_provider_registry); + self + } + pub fn env_lookup( mut self, env_lookup: impl Fn(&str) -> Option + Send + Sync + 'static, @@ -222,6 +233,7 @@ impl TestAppStateBuilder { http_client: Some( fabro_http::test_http_client().expect("test HTTP client should build"), ), + sandbox_provider_registry: self.sandbox_provider_registry, shutdown: CancellationToken::new(), }) .expect("test app state should build") diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 18aa1993c..552891d2e 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -14,7 +14,7 @@ use fabro_types::{ PullRequestLink, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox, RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, - SandboxProvider, StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, + SandboxProviderKind, StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, TodoProjection, WorkflowRef, first_event_seq, }; @@ -796,7 +796,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result { } fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox { - let provider = SandboxProvider::from(settings.provider); + let provider = SandboxProviderKind::from(settings.provider); RunSandbox { provider, image: (settings.provider == EnvironmentProvider::Docker) diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index 2707ca353..f6aa256fd 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -6,7 +6,7 @@ use fabro_types::graph::Graph; use fabro_types::run::RunSpec; use fabro_types::{ BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord, - QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProvider, + QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord, WorkflowSettings, first_event_seq, fixtures, }; @@ -99,7 +99,7 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { diff: RunDiff::default(), }); projection.sandbox = Some(RunSandbox { - provider: SandboxProvider::Local, + provider: SandboxProviderKind::Local, image: None, snapshot: None, runtime: Some(RunSandboxRuntime { diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index d2bbdfbcd..af4ae44e8 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -31,6 +31,7 @@ pub mod run_sandbox; pub mod run_summary; pub mod run_title; pub mod sandbox_details; +pub mod sandbox_inventory; pub mod sandbox_provider; pub mod sandbox_services; pub mod secret; @@ -123,7 +124,10 @@ pub use sandbox_details::{ SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxResources, SandboxState, SandboxTimestamps, }; -pub use sandbox_provider::SandboxProvider; +pub use sandbox_inventory::{ + SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderLookupError, +}; +pub use sandbox_provider::SandboxProviderKind; pub use sandbox_services::{ SandboxService, SandboxServiceDiscoverySource, SandboxServiceListMeta, SandboxServiceListResponse, diff --git a/lib/crates/fabro-types/src/run_event/infra.rs b/lib/crates/fabro-types/src/run_event/infra.rs index adb8f364e..e6673ff24 100644 --- a/lib/crates/fabro-types/src/run_event/infra.rs +++ b/lib/crates/fabro-types/src/run_event/infra.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::SandboxProvider; +use crate::SandboxProviderKind; #[derive( Debug, @@ -329,7 +329,7 @@ pub struct GitCloneFailedProps { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SandboxInitializedProps { pub working_directory: String, - pub provider: SandboxProvider, + pub provider: SandboxProviderKind, pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub repo_cloned: Option, diff --git a/lib/crates/fabro-types/src/run_sandbox.rs b/lib/crates/fabro-types/src/run_sandbox.rs index 808dfde24..b2aedd829 100644 --- a/lib/crates/fabro-types/src/run_sandbox.rs +++ b/lib/crates/fabro-types/src/run_sandbox.rs @@ -1,10 +1,10 @@ use serde::{Deserialize, Serialize}; -use crate::SandboxProvider; +use crate::SandboxProviderKind; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunSandbox { - pub provider: SandboxProvider, + pub provider: SandboxProviderKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub image: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/sandbox_details.rs b/lib/crates/fabro-types/src/sandbox_details.rs index ecbf21a12..8723b00eb 100644 --- a/lib/crates/fabro-types/src/sandbox_details.rs +++ b/lib/crates/fabro-types/src/sandbox_details.rs @@ -188,7 +188,7 @@ mod tests { fn serializes_with_snake_case_state() { let details = SandboxDetails { sandbox: RunSandbox { - provider: crate::SandboxProvider::Docker, + provider: crate::SandboxProviderKind::Docker, image: Some("ghcr.io/fabro/sandbox:latest".to_string()), snapshot: None, runtime: Some(crate::RunSandboxRuntime { @@ -282,7 +282,7 @@ mod tests { })) .unwrap(); - assert_eq!(details.sandbox.provider, crate::SandboxProvider::Local); + assert_eq!(details.sandbox.provider, crate::SandboxProviderKind::Local); assert_eq!( details .sandbox diff --git a/lib/crates/fabro-types/src/sandbox_inventory.rs b/lib/crates/fabro-types/src/sandbox_inventory.rs new file mode 100644 index 000000000..3acef2a78 --- /dev/null +++ b/lib/crates/fabro-types/src/sandbox_inventory.rs @@ -0,0 +1,52 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{ + SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxInfo { + pub provider: SandboxProviderKind, + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub state: SandboxState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + pub resources: SandboxResources, + #[serde(default)] + pub network: SandboxNetwork, + #[serde(default)] + pub labels: BTreeMap, + pub timestamps: SandboxTimestamps, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxProviderLookupError { + pub provider: SandboxProviderKind, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SandboxListMeta { + #[serde(default)] + pub provider_errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxListResponse { + pub data: Vec, + pub meta: SandboxListMeta, +} diff --git a/lib/crates/fabro-types/src/sandbox_provider.rs b/lib/crates/fabro-types/src/sandbox_provider.rs index 1034fe2b2..49e3969b6 100644 --- a/lib/crates/fabro-types/src/sandbox_provider.rs +++ b/lib/crates/fabro-types/src/sandbox_provider.rs @@ -3,13 +3,13 @@ use strum::{Display, EnumString}; use crate::settings::run::RunMode; -/// Sandbox provider for agent tool operations. +/// Sandbox provider discriminator for agent tool operations. #[derive( Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Display, EnumString, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase", ascii_case_insensitive)] -pub enum SandboxProvider { +pub enum SandboxProviderKind { /// Run tools on the local host. #[default] Local, @@ -19,7 +19,7 @@ pub enum SandboxProvider { Daytona, } -impl SandboxProvider { +impl SandboxProviderKind { /// True only for Local. Used by dry-run to force local execution. /// NOT the same as "runs on the host" (Docker is host-adjacent but not /// dry-run compatible). @@ -48,38 +48,38 @@ impl SandboxProvider { #[cfg(test)] mod tests { - use super::SandboxProvider; + use super::SandboxProviderKind; #[test] fn sandbox_provider_default_is_local() { - assert_eq!(SandboxProvider::default(), SandboxProvider::Local); + assert_eq!(SandboxProviderKind::default(), SandboxProviderKind::Local); } #[test] fn sandbox_provider_from_str() { assert_eq!( - "local".parse::().unwrap(), - SandboxProvider::Local + "local".parse::().unwrap(), + SandboxProviderKind::Local ); assert_eq!( - "docker".parse::().unwrap(), - SandboxProvider::Docker + "docker".parse::().unwrap(), + SandboxProviderKind::Docker ); assert_eq!( - "daytona".parse::().unwrap(), - SandboxProvider::Daytona + "daytona".parse::().unwrap(), + SandboxProviderKind::Daytona ); assert_eq!( - "LOCAL".parse::().unwrap(), - SandboxProvider::Local + "LOCAL".parse::().unwrap(), + SandboxProviderKind::Local ); - assert!("invalid".parse::().is_err()); + assert!("invalid".parse::().is_err()); } #[test] fn sandbox_provider_display() { - assert_eq!(SandboxProvider::Local.to_string(), "local"); - assert_eq!(SandboxProvider::Docker.to_string(), "docker"); - assert_eq!(SandboxProvider::Daytona.to_string(), "daytona"); + assert_eq!(SandboxProviderKind::Local.to_string(), "local"); + assert_eq!(SandboxProviderKind::Docker.to_string(), "docker"); + assert_eq!(SandboxProviderKind::Daytona.to_string(), "daytona"); } } diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index 473d8b809..a4ff1132c 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -323,7 +323,7 @@ impl EnvironmentProvider { } } -impl From for crate::SandboxProvider { +impl From for crate::SandboxProviderKind { fn from(value: EnvironmentProvider) -> Self { match value { EnvironmentProvider::Local => Self::Local, diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index c6fe71305..cccbc77e1 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -150,11 +150,14 @@ pub struct ServerSandboxProvidersSettings { impl ServerSandboxProvidersSettings { /// Per-provider policy entry. #[must_use] - pub fn for_provider(&self, provider: crate::SandboxProvider) -> &ServerSandboxProviderSettings { + pub fn for_provider( + &self, + provider: crate::SandboxProviderKind, + ) -> &ServerSandboxProviderSettings { match provider { - crate::SandboxProvider::Local => &self.local, - crate::SandboxProvider::Docker => &self.docker, - crate::SandboxProvider::Daytona => &self.daytona, + crate::SandboxProviderKind::Local => &self.local, + crate::SandboxProviderKind::Docker => &self.docker, + crate::SandboxProviderKind::Daytona => &self.daytona, } } } diff --git a/lib/crates/fabro-types/tests/sandbox_inventory_serde.rs b/lib/crates/fabro-types/tests/sandbox_inventory_serde.rs new file mode 100644 index 000000000..10931b65d --- /dev/null +++ b/lib/crates/fabro-types/tests/sandbox_inventory_serde.rs @@ -0,0 +1,119 @@ +use std::collections::BTreeMap; + +use chrono::{TimeZone, Utc}; +use fabro_types::{ + SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, + SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxState, + SandboxTimestamps, +}; +use serde_json::json; + +#[test] +fn sandbox_inventory_serializes_provider_backed_shape() { + let created_at = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(); + let response = SandboxListResponse { + data: vec![SandboxInfo { + provider: SandboxProviderKind::Docker, + id: "container-abc123".to_string(), + display_name: Some("fabro-run-abc".to_string()), + state: SandboxState::Running, + native_state: Some("running".to_string()), + image: Some("buildpack-deps:noble".to_string()), + snapshot: None, + region: None, + web_url: None, + working_directory: Some("/workspace".to_string()), + resources: SandboxResources { + cpu_cores: Some(2.0), + memory_bytes: Some(4 * 1024 * 1024 * 1024), + disk_bytes: None, + }, + network: SandboxNetwork { + egress: SandboxNetworkPolicy::open(), + ingress: SandboxNetworkPolicy::blocked(), + }, + labels: BTreeMap::from([( + "sh.fabro.managed".to_string(), + "true".to_string(), + )]), + timestamps: SandboxTimestamps { + created_at: Some(created_at), + last_activity_at: None, + }, + }], + meta: SandboxListMeta { + provider_errors: vec![SandboxProviderLookupError { + provider: SandboxProviderKind::Daytona, + message: "Daytona API key is not configured".to_string(), + }], + }, + }; + + assert_eq!( + serde_json::to_value(&response).unwrap(), + json!({ + "data": [{ + "provider": "docker", + "id": "container-abc123", + "display_name": "fabro-run-abc", + "state": "running", + "native_state": "running", + "image": "buildpack-deps:noble", + "working_directory": "/workspace", + "resources": { + "cpu_cores": 2.0, + "memory_bytes": 4_294_967_296_u64 + }, + "network": { + "egress": { + "mode": "open", + "cidrs": [] + }, + "ingress": { + "mode": "blocked", + "cidrs": [] + } + }, + "labels": { + "sh.fabro.managed": "true" + }, + "timestamps": { + "created_at": "2026-05-25T12:00:00Z" + } + }], + "meta": { + "provider_errors": [{ + "provider": "daytona", + "message": "Daytona API key is not configured" + }] + } + }) + ); +} + +#[test] +fn sandbox_inventory_deserializes_when_optional_fields_are_absent() { + let info: SandboxInfo = serde_json::from_value(json!({ + "provider": "local", + "id": "local:01KSGHGMCFM8W2FHXNMJ7MVY65", + "state": "unknown", + "resources": {}, + "timestamps": {} + })) + .unwrap(); + + assert_eq!(info.provider, SandboxProviderKind::Local); + assert_eq!(info.id, "local:01KSGHGMCFM8W2FHXNMJ7MVY65"); + assert_eq!(info.state, SandboxState::Unknown); + assert!(info.display_name.is_none()); + assert!(info.native_state.is_none()); + assert!(info.image.is_none()); + assert!(info.snapshot.is_none()); + assert!(info.region.is_none()); + assert!(info.web_url.is_none()); + assert!(info.working_directory.is_none()); + assert_eq!(info.resources, SandboxResources::default()); + assert_eq!(info.network, SandboxNetwork::unknown()); + assert!(info.labels.is_empty()); + assert_eq!(info.timestamps, SandboxTimestamps::default()); +} diff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/crates/fabro-types/tests/sandbox_model_serde.rs index 0260a8132..4db0f34b2 100644 --- a/lib/crates/fabro-types/tests/sandbox_model_serde.rs +++ b/lib/crates/fabro-types/tests/sandbox_model_serde.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use chrono::{TimeZone, Utc}; use fabro_types::{ - RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProvider, + RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, }; use serde_json::json; @@ -10,7 +10,7 @@ use serde_json::json; #[test] fn run_sandbox_serializes_canonical_identity_without_identifier() { let sandbox = RunSandbox { - provider: SandboxProvider::Docker, + provider: SandboxProviderKind::Docker, image: None, snapshot: None, runtime: Some(RunSandboxRuntime { @@ -52,7 +52,7 @@ fn run_sandbox_serializes_canonical_identity_without_identifier() { fn sandbox_details_requires_canonical_id_and_working_directory() { let details = SandboxDetails { sandbox: RunSandbox { - provider: SandboxProvider::Daytona, + provider: SandboxProviderKind::Daytona, image: Some("ubuntu:24.04".to_string()), snapshot: None, runtime: Some(RunSandboxRuntime { @@ -116,16 +116,16 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { #[test] fn sandbox_provider_rejects_unknown_values() { assert_eq!( - serde_json::from_value::(json!("local")).unwrap(), - SandboxProvider::Local + serde_json::from_value::(json!("local")).unwrap(), + SandboxProviderKind::Local ); assert_eq!( - serde_json::from_value::(json!("docker")).unwrap(), - SandboxProvider::Docker + serde_json::from_value::(json!("docker")).unwrap(), + SandboxProviderKind::Docker ); assert_eq!( - serde_json::from_value::(json!("daytona")).unwrap(), - SandboxProvider::Daytona + serde_json::from_value::(json!("daytona")).unwrap(), + SandboxProviderKind::Daytona ); - assert!(serde_json::from_value::(json!("other")).is_err()); + assert!(serde_json::from_value::(json!("other")).is_err()); } diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index a38b184bb..cf458c121 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -5,7 +5,7 @@ use ::fabro_types::{ ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget, ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, RunBlobId, RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, - RunRunnableSource, RunTiming, SandboxProvider, StageId, StageTiming, SuccessReason, + RunRunnableSource, RunTiming, SandboxProviderKind, StageId, StageTiming, SuccessReason, run_event as fabro_types, }; use fabro_agent::{AgentEvent, SandboxEvent}; @@ -506,7 +506,7 @@ pub enum Event { /// Emitted after the sandbox has been initialized (by engine lifecycle). SandboxInitialized { working_directory: String, - provider: SandboxProvider, + provider: SandboxProviderKind, id: String, #[serde(default, skip_serializing_if = "Option::is_none")] repo_cloned: Option, diff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/crates/fabro-workflow/src/operations/retry.rs index 9a9d3c705..6551687c5 100644 --- a/lib/crates/fabro-workflow/src/operations/retry.rs +++ b/lib/crates/fabro-workflow/src/operations/retry.rs @@ -301,7 +301,7 @@ mod tests { .await .unwrap(); event::append_event(&source_store, &source_run_id, &Event::SandboxInitialized { - provider: fabro_types::SandboxProvider::Local, + provider: fabro_types::SandboxProviderKind::Local, id: "sandbox-source".to_string(), working_directory: "/tmp/source".to_string(), repo_cloned: None, diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index ec300d581..a5551ff72 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -12,7 +12,7 @@ use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::from_environment::{ daytona_config_from_environment, docker_config_from_environment, }; -use fabro_sandbox::{DockerSandboxOptions, SandboxProvider, SandboxSpec}; +use fabro_sandbox::{DockerSandboxOptions, SandboxSpec}; use fabro_static::EnvVars; use fabro_types::settings::run::{ ApprovalMode, HookDefinition as ResolvedHookDefinition, HookEvent as ResolvedHookEvent, @@ -22,7 +22,7 @@ use fabro_types::settings::run::{ TlsMode as ResolvedTlsMode, }; use fabro_types::settings::{InterpString, ModelRegistry, ResolvedModelRef}; -use fabro_types::{ManifestPath, RunId, RunRunnableSource}; +use fabro_types::{ManifestPath, RunId, RunRunnableSource, SandboxProviderKind}; use fabro_vault::Vault; use tokio::runtime::Handle; use tokio::sync::RwLock as AsyncRwLock; @@ -387,17 +387,17 @@ impl RunSession { .collect(); let sandbox = match sandbox_provider { - SandboxProvider::Local => SandboxSpec::Local { + SandboxProviderKind::Local => SandboxSpec::Local { working_directory: working_directory.clone(), }, - SandboxProvider::Docker => SandboxSpec::Docker { + SandboxProviderKind::Docker => SandboxSpec::Docker { config: resolve_docker_config(resolved), github_app: services.github_app.clone(), run_id: Some(record.run_id), clone_origin_url: record.repo_origin_url().map(str::to_string), clone_branch: record.base_branch().map(str::to_string), }, - SandboxProvider::Daytona => { + SandboxProviderKind::Daytona => { let api_key = match &services.vault { Some(v) => v .read() @@ -553,8 +553,8 @@ 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) -> SandboxProvider { - SandboxProvider::from(settings.environment.provider) +fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProviderKind { + SandboxProviderKind::from(settings.environment.provider) } fn resolve_daytona_config(settings: &ResolvedRunSettings) -> DaytonaConfig { diff --git a/lib/crates/fabro-workflow/tests/it/cp_integration.rs b/lib/crates/fabro-workflow/tests/it/cp_integration.rs index e90b54a93..60aafd9bb 100644 --- a/lib/crates/fabro-workflow/tests/it/cp_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/cp_integration.rs @@ -15,7 +15,7 @@ )] use fabro_sandbox::reconnect::reconnect; -use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider}; +use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind}; const DOCKER_MANAGED_LABEL: &str = "sh.fabro.managed"; const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble"; @@ -26,7 +26,7 @@ const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble"; fn local_record(working_directory: &std::path::Path) -> RunSandbox { RunSandbox { - provider: SandboxProvider::Local, + provider: SandboxProviderKind::Local, image: None, snapshot: None, runtime: Some(RunSandboxRuntime { @@ -137,7 +137,7 @@ async fn local_cp_creates_parent_dirs() { fn docker_record(container_id: &str) -> RunSandbox { RunSandbox { - provider: SandboxProvider::Docker, + provider: SandboxProviderKind::Docker, image: None, snapshot: None, runtime: Some(RunSandboxRuntime { diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 0115f23df..0c8bd171d 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -1704,7 +1704,7 @@ async fn daytona_toolbox_idle_diagnostic() { #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_cp_upload_download_round_trip() { use fabro_sandbox::reconnect::reconnect; - use fabro_types::{RunSandbox, SandboxProvider}; + use fabro_types::{RunSandbox, SandboxProviderKind}; // 1. Create and initialize a real Daytona sandbox let env = create_env().await; @@ -1718,7 +1718,7 @@ async fn daytona_cp_upload_download_round_trip() { // 2. Build a RunSandbox (same as `fabro run` would persist) let record = RunSandbox { - provider: SandboxProvider::Daytona, + provider: SandboxProviderKind::Daytona, image: None, snapshot: None, runtime: Some(fabro_types::RunSandboxRuntime { diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index afa4aa5fd..a00c4c9c2 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -12,6 +12,7 @@ api/repos-api.ts api/run-internals-api.ts api/run-outputs-api.ts api/runs-api.ts +api/sandboxes-api.ts api/secrets-api.ts api/sessions-api.ts api/settings-api.ts @@ -370,10 +371,14 @@ models/run.ts models/sandbox-details.ts models/sandbox-file-entry.ts models/sandbox-file-list-response.ts +models/sandbox-info.ts +models/sandbox-list-meta.ts +models/sandbox-list-response.ts models/sandbox-network-policy-mode.ts models/sandbox-network-policy.ts models/sandbox-network.ts -models/sandbox-provider.ts +models/sandbox-provider-kind.ts +models/sandbox-provider-lookup-error.ts models/sandbox-resources.ts models/sandbox-service-discovery-source.ts models/sandbox-service-list-meta.ts diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index bda32eb1e..22fabf4d5 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -27,6 +27,7 @@ export * from './api/repos-api'; export * from './api/run-internals-api'; export * from './api/run-outputs-api'; export * from './api/runs-api'; +export * from './api/sandboxes-api'; export * from './api/secrets-api'; export * from './api/sessions-api'; export * from './api/settings-api'; diff --git a/lib/packages/fabro-api-client/src/api/sandboxes-api.ts b/lib/packages/fabro-api-client/src/api/sandboxes-api.ts new file mode 100644 index 000000000..87ddcd56a --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/sandboxes-api.ts @@ -0,0 +1,200 @@ +/* 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. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { ErrorResponse } from '../models'; +// @ts-ignore +import type { SandboxInfo } from '../models'; +// @ts-ignore +import type { SandboxListResponse } from '../models'; +/** + * SandboxesApi - axios parameter creator + */ +export const SandboxesApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Lists Fabro-managed sandboxes directly from configured sandbox providers. + * @summary List Sandboxes + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listSandboxes: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/sandboxes`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers. + * @summary Retrieve Sandbox + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + retrieveSandbox: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('retrieveSandbox', 'id', id) + const localVarPath = `/api/v1/sandboxes/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * SandboxesApi - functional programming interface + */ +export const SandboxesApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = SandboxesApiAxiosParamCreator(configuration) + return { + /** + * Lists Fabro-managed sandboxes directly from configured sandbox providers. + * @summary List Sandboxes + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listSandboxes(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listSandboxes(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SandboxesApi.listSandboxes']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers. + * @summary Retrieve Sandbox + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async retrieveSandbox(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveSandbox(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SandboxesApi.retrieveSandbox']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * SandboxesApi - factory interface + */ +export const SandboxesApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = SandboxesApiFp(configuration) + return { + /** + * Lists Fabro-managed sandboxes directly from configured sandbox providers. + * @summary List Sandboxes + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listSandboxes(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listSandboxes(options).then((request) => request(axios, basePath)); + }, + /** + * Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers. + * @summary Retrieve Sandbox + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + retrieveSandbox(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.retrieveSandbox(id, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * SandboxesApi - object-oriented interface + */ +export class SandboxesApi extends BaseAPI { + /** + * Lists Fabro-managed sandboxes directly from configured sandbox providers. + * @summary List Sandboxes + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public listSandboxes(options?: RawAxiosRequestConfig) { + return SandboxesApiFp(this.configuration).listSandboxes(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers. + * @summary Retrieve Sandbox + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public retrieveSandbox(id: string, options?: RawAxiosRequestConfig) { + return SandboxesApiFp(this.configuration).retrieveSandbox(id, options).then((request) => request(this.axios, this.basePath)); + } +} diff --git a/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts b/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts index cc8410346..349ce716d 100644 --- a/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts +++ b/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts @@ -15,9 +15,9 @@ // May contain unused imports in some cases // @ts-ignore -import type { SandboxProvider } from './sandbox-provider'; +import type { SandboxProviderKind } from './sandbox-provider-kind'; export interface DeleteRunSandbox { - 'provider': SandboxProvider; + 'provider': SandboxProviderKind; 'id': string; } diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 2ec1e49a4..f0e270b7a 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -346,10 +346,14 @@ export * from './run-timing'; export * from './sandbox-details'; export * from './sandbox-file-entry'; export * from './sandbox-file-list-response'; +export * from './sandbox-info'; +export * from './sandbox-list-meta'; +export * from './sandbox-list-response'; export * from './sandbox-network'; export * from './sandbox-network-policy'; export * from './sandbox-network-policy-mode'; -export * from './sandbox-provider'; +export * from './sandbox-provider-kind'; +export * from './sandbox-provider-lookup-error'; export * from './sandbox-resources'; export * from './sandbox-service'; export * from './sandbox-service-discovery-source'; diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox.ts b/lib/packages/fabro-api-client/src/models/run-sandbox.ts index a06c1ad64..7e5102b1b 100644 --- a/lib/packages/fabro-api-client/src/models/run-sandbox.ts +++ b/lib/packages/fabro-api-client/src/models/run-sandbox.ts @@ -18,13 +18,13 @@ import type { RunSandboxRuntime } from './run-sandbox-runtime'; // May contain unused imports in some cases // @ts-ignore -import type { SandboxProvider } from './sandbox-provider'; +import type { SandboxProviderKind } from './sandbox-provider-kind'; /** * Canonical sandbox environment record for a run. */ export interface RunSandbox { - 'provider': SandboxProvider; + 'provider': SandboxProviderKind; 'image': string | null; 'snapshot': string | null; 'runtime': RunSandboxRuntime | null; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-info.ts b/lib/packages/fabro-api-client/src/models/sandbox-info.ts new file mode 100644 index 000000000..9f026d745 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-info.ts @@ -0,0 +1,77 @@ +/* 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 { SandboxNetwork } from './sandbox-network'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxProviderKind } from './sandbox-provider-kind'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxResources } from './sandbox-resources'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxState } from './sandbox-state'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxTimestamps } from './sandbox-timestamps'; + +/** + * Provider-backed inventory record for a Fabro-managed sandbox. + */ +export interface SandboxInfo { + 'provider': SandboxProviderKind; + /** + * Provider-native sandbox id. + */ + 'id': string; + /** + * Provider display name when distinct from the native id. + */ + 'display_name'?: string | null; + 'state': SandboxState; + /** + * Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`. + */ + 'native_state'?: string | null; + /** + * Provider image when surfaced by the sandbox provider. + */ + 'image'?: string | null; + /** + * Provider snapshot when surfaced by the sandbox provider. + */ + 'snapshot'?: string | null; + /** + * Provider region or target. Null for local-style providers. + */ + 'region'?: string | null; + /** + * Provider dashboard URL for this sandbox when available. + */ + 'web_url'?: string | null; + /** + * Provider-reported or Fabro-default working directory when available. + */ + 'working_directory'?: string | null; + 'resources': SandboxResources; + 'network': SandboxNetwork; + /** + * Provider-reported labels. + */ + 'labels': { [key: string]: string; }; + 'timestamps': SandboxTimestamps; +} diff --git a/lib/packages/fabro-api-client/src/models/sandbox-list-meta.ts b/lib/packages/fabro-api-client/src/models/sandbox-list-meta.ts new file mode 100644 index 000000000..a8258b994 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-list-meta.ts @@ -0,0 +1,25 @@ +/* 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 { SandboxProviderLookupError } from './sandbox-provider-lookup-error'; + +/** + * Metadata for provider-backed sandbox inventory. + */ +export interface SandboxListMeta { + 'provider_errors': Array; +} diff --git a/lib/packages/fabro-api-client/src/models/sandbox-list-response.ts b/lib/packages/fabro-api-client/src/models/sandbox-list-response.ts new file mode 100644 index 000000000..7ac3f6824 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-list-response.ts @@ -0,0 +1,29 @@ +/* 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 { SandboxInfo } from './sandbox-info'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxListMeta } from './sandbox-list-meta'; + +/** + * Non-paginated provider-backed sandbox inventory response. + */ +export interface SandboxListResponse { + 'data': Array; + 'meta': SandboxListMeta; +} diff --git a/lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts b/lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts new file mode 100644 index 000000000..57f0c222c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-provider-kind.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. + */ + + + +/** + * Sandbox provider discriminator. + */ + +export const SandboxProviderKind = { + LOCAL: 'local', + DOCKER: 'docker', + DAYTONA: 'daytona' +} as const; + +export type SandboxProviderKind = typeof SandboxProviderKind[keyof typeof SandboxProviderKind]; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-provider-lookup-error.ts b/lib/packages/fabro-api-client/src/models/sandbox-provider-lookup-error.ts new file mode 100644 index 000000000..d5fee5806 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-provider-lookup-error.ts @@ -0,0 +1,26 @@ +/* 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 { SandboxProviderKind } from './sandbox-provider-kind'; + +/** + * Provider error captured during fail-soft sandbox inventory lookup. + */ +export interface SandboxProviderLookupError { + 'provider': SandboxProviderKind; + 'message': string; +}