mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
Add provider-backed sandbox inventory API and rename SandboxProvider to… (#409)
## 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
<details>
<summary>Ran 8 stages in 102m 54s for $41.81</summary>
| 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** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="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
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
parent
62f0b3e7d1
commit
bd72570437
57 changed files with 2379 additions and 228 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::<ApiRunSandbox, RunSandbox>();
|
||||
assert_same_type::<ApiSandboxProvider, SandboxProvider>();
|
||||
assert_same_type::<ApiSandboxProvider, SandboxProviderKind>();
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
|
|
|||
|
|
@ -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::<ApiSandboxDetails, SandboxDetails>();
|
||||
assert_same_type::<ApiSandboxProvider, SandboxProvider>();
|
||||
assert_same_type::<ApiSandboxProvider, SandboxProviderKind>();
|
||||
assert_same_type::<ApiSandboxState, SandboxState>();
|
||||
assert_same_type::<ApiSandboxResources, SandboxResources>();
|
||||
assert_same_type::<ApiSandboxTimestamps, SandboxTimestamps>();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
123
lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs
Normal file
123
lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs
Normal file
|
|
@ -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::<ApiSandboxProviderKind, SandboxProviderKind>();
|
||||
assert_same_type::<ApiSandboxInfo, SandboxInfo>();
|
||||
assert_same_type::<ApiSandboxProviderLookupError, SandboxProviderLookupError>();
|
||||
assert_same_type::<ApiSandboxListMeta, SandboxListMeta>();
|
||||
assert_same_type::<ApiSandboxListResponse, SandboxListResponse>();
|
||||
}
|
||||
|
||||
#[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<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
api_url: Option<String>,
|
||||
organization_id: Option<String>,
|
||||
|
|
|
|||
|
|
@ -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<RunId>,
|
||||
) -> Result<SandboxDetails> {
|
||||
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<DateTime<Utc>> {
|
|||
}
|
||||
|
||||
#[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::<InspectContainerOptions>)
|
||||
.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<String>,
|
||||
state: SandboxState,
|
||||
native_state: Option<String>,
|
||||
image: Option<String>,
|
||||
working_directory: Option<String>,
|
||||
resources: SandboxResources,
|
||||
network: SandboxNetwork,
|
||||
labels: BTreeMap<String, String>,
|
||||
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<String, String> = 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<String>,
|
||||
region: Option<String>,
|
||||
resources: SandboxResources,
|
||||
network: SandboxNetwork,
|
||||
labels: BTreeMap<String, String>,
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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<String, String>) -> 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<String, String> {
|
||||
let mut labels = HashMap::new();
|
||||
|
|
@ -23,7 +30,7 @@ pub(crate) fn merge_for_run(
|
|||
}
|
||||
|
||||
fn insert_for_run(labels: &mut HashMap<String, String>, 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]
|
||||
|
|
|
|||
360
lib/crates/fabro-sandbox/src/provider.rs
Normal file
360
lib/crates/fabro-sandbox/src/provider.rs
Normal file
|
|
@ -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<GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
},
|
||||
#[cfg(feature = "daytona")]
|
||||
Daytona {
|
||||
config: Box<DaytonaConfig>,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
api_key: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SandboxProvider: Send + Sync {
|
||||
fn kind(&self) -> SandboxProviderKind;
|
||||
|
||||
async fn list(&self) -> crate::Result<Vec<SandboxInfo>>;
|
||||
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>>;
|
||||
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo>;
|
||||
async fn delete(&self, id: &str) -> crate::Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SandboxProviderRegistry {
|
||||
providers: Vec<Arc<dyn SandboxProvider>>,
|
||||
}
|
||||
|
||||
impl SandboxProviderRegistry {
|
||||
pub fn new(providers: Vec<Arc<dyn SandboxProvider>>) -> Self {
|
||||
Self { providers }
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn providers(&self) -> &[Arc<dyn SandboxProvider>] {
|
||||
&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<SandboxInfo, SandboxLookupError> {
|
||||
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<SandboxProviderKind>,
|
||||
},
|
||||
#[error("sandbox '{id}' could not be found definitively because one or more providers failed")]
|
||||
ProviderUnavailable {
|
||||
id: String,
|
||||
provider_errors: Vec<SandboxProviderLookupError>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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<Vec<SandboxInfo>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get(&self, _id: &str) -> crate::Result<Option<SandboxInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
|
||||
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(),
|
||||
}]
|
||||
));
|
||||
}
|
||||
}
|
||||
172
lib/crates/fabro-sandbox/src/provider/daytona.rs
Normal file
172
lib/crates/fabro-sandbox/src/provider/daytona.rs
Normal file
|
|
@ -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<String>,
|
||||
api_url: Option<String>,
|
||||
organization_id: Option<String>,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
}
|
||||
|
||||
impl DaytonaSandboxProvider {
|
||||
pub fn new(
|
||||
api_key: Option<String>,
|
||||
api_url: Option<String>,
|
||||
organization_id: Option<String>,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
api_url,
|
||||
organization_id,
|
||||
http_client,
|
||||
}
|
||||
}
|
||||
|
||||
async fn client(&self) -> crate::Result<daytona_sdk::Client> {
|
||||
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<Vec<SandboxInfo>> {
|
||||
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<Option<SandboxInfo>> {
|
||||
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<SandboxInfo> {
|
||||
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)
|
||||
}
|
||||
163
lib/crates/fabro-sandbox/src/provider/docker.rs
Normal file
163
lib/crates/fabro-sandbox/src/provider/docker.rs
Normal file
|
|
@ -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> {
|
||||
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<Vec<SandboxInfo>> {
|
||||
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::<String> {
|
||||
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<String> = 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::<InspectContainerOptions>)),
|
||||
)
|
||||
.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<Option<SandboxInfo>> {
|
||||
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<SandboxInfo> {
|
||||
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<Option<ContainerInspectResponse>> {
|
||||
match docker
|
||||
.inspect_container(id, None::<InspectContainerOptions>)
|
||||
.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,
|
||||
..
|
||||
})
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SandboxInfo>),
|
||||
Err(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum FakeGet {
|
||||
Found(Box<SandboxInfo>),
|
||||
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<Vec<SandboxInfo>> {
|
||||
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<Option<SandboxInfo>> {
|
||||
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<SandboxInfo> {
|
||||
Err(crate::Error::message("not implemented"))
|
||||
}
|
||||
|
||||
async fn delete(&self, _id: &str) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fake_registry(providers: Vec<FakeSandboxProvider>) -> SandboxProviderRegistry {
|
||||
SandboxProviderRegistry::new(
|
||||
providers
|
||||
.into_iter()
|
||||
.map(|provider| Arc::new(provider) as Arc<dyn SandboxProvider>)
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Chec
|
|||
|
||||
pub(crate) fn sandbox_provider_policy_error(
|
||||
server_settings: &ServerSettings,
|
||||
provider: SandboxProvider,
|
||||
provider: SandboxProviderKind,
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<CheckDetail> {
|
|||
|
||||
async fn run_repository_access_check(
|
||||
checks: &mut Vec<CheckResult>,
|
||||
sandbox_provider: SandboxProvider,
|
||||
sandbox_provider: SandboxProviderKind,
|
||||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
|
|
@ -760,7 +760,7 @@ async fn run_repository_access_check(
|
|||
|
||||
async fn run_repository_access_check_with<F, Fut>(
|
||||
checks: &mut Vec<CheckResult>,
|
||||
sandbox_provider: SandboxProvider,
|
||||
sandbox_provider: SandboxProviderKind,
|
||||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
|
|
@ -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<fabro_github::GitHubCredentials>,
|
||||
|
|
@ -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<CheckResult>,
|
||||
sandbox_provider: SandboxProvider,
|
||||
sandbox_provider: SandboxProviderKind,
|
||||
prepared: &PreparedManifest,
|
||||
resolved_run: &RunNamespace,
|
||||
github_app: Option<fabro_github::GitHubCredentials>,
|
||||
|
|
@ -934,7 +934,7 @@ async fn run_sandbox_check(
|
|||
daytona_api_key,
|
||||
);
|
||||
let sandbox_result: Result<Arc<dyn Sandbox>, 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<types::GitContext>,
|
||||
) -> (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 {
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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<fabro_http::HttpClient>,
|
||||
sandbox_provider_registry: SandboxProviderRegistry,
|
||||
shutdown: CancellationToken,
|
||||
shutting_down: AtomicBool,
|
||||
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
|
||||
|
|
@ -1060,6 +1064,7 @@ pub(crate) struct AppStateConfig {
|
|||
pub(crate) github_api_base_url: Option<String>,
|
||||
pub(crate) active_config_path: PathBuf,
|
||||
pub(crate) http_client: Option<fabro_http::HttpClient>,
|
||||
pub(crate) sandbox_provider_registry: Option<SandboxProviderRegistry>,
|
||||
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<String> {
|
||||
self.server_secrets.get(name)
|
||||
}
|
||||
|
|
@ -2002,7 +2011,7 @@ fn system_sandbox_provider(
|
|||
manifest_run_settings: &std::result::Result<RunNamespace, SharedError>,
|
||||
) -> 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<String>,
|
||||
env_lookup: &EnvLookup,
|
||||
http_client: Option<fabro_http::HttpClient>,
|
||||
) -> SandboxProviderRegistry {
|
||||
let provider_settings = &server_settings.server.sandbox.providers;
|
||||
let mut providers: Vec<Arc<dyn SandboxProvider>> = 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<Arc<AppState>> {
|
||||
let AppStateConfig {
|
||||
resolved_settings,
|
||||
|
|
@ -2064,6 +2105,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
github_api_base_url,
|
||||
active_config_path,
|
||||
http_client,
|
||||
sandbox_provider_registry,
|
||||
shutdown,
|
||||
} = config;
|
||||
|
||||
|
|
@ -2071,6 +2113,9 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
Some(vault) => 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<dyn CredentialSource> =
|
||||
Arc::new(VaultCredentialSource::vault_only(Arc::clone(&vault)));
|
||||
|
|
@ -2084,6 +2129,14 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
Catalog::from_builtin_with_overrides(&resolved_settings.llm_catalog_settings)
|
||||
.context("building LLM model catalog")?,
|
||||
);
|
||||
let sandbox_provider_registry = sandbox_provider_registry.unwrap_or_else(|| {
|
||||
build_sandbox_provider_registry(
|
||||
current_server_settings.as_ref(),
|
||||
daytona_api_key,
|
||||
&env_lookup,
|
||||
http_client.clone(),
|
||||
)
|
||||
});
|
||||
let slack_service = {
|
||||
let default_channel = current_server_settings
|
||||
.server
|
||||
|
|
@ -2153,6 +2206,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
github_api_base_url,
|
||||
active_config_path,
|
||||
http_client,
|
||||
sandbox_provider_registry,
|
||||
shutdown,
|
||||
shutting_down: AtomicBool::new(false),
|
||||
registry_factory_override,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ mod pair;
|
|||
mod pull_requests;
|
||||
mod runs;
|
||||
mod sandbox;
|
||||
mod sandboxes;
|
||||
mod secrets;
|
||||
mod sessions;
|
||||
mod steer;
|
||||
|
|
@ -153,6 +154,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
|
|||
.merge(pull_requests::routes())
|
||||
.merge(artifacts::routes())
|
||||
.merge(sandbox::routes())
|
||||
.merge(sandboxes::routes())
|
||||
.merge(lifecycle::routes())
|
||||
.merge(steer::routes())
|
||||
.merge(pair::routes())
|
||||
|
|
|
|||
|
|
@ -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<SandboxService> {
|
||||
fn parse_ss_listening_services(output: &str, provider: SandboxProviderKind) -> Vec<SandboxService> {
|
||||
let mut services = BTreeMap::<u16, SandboxService>::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<SandboxService> {
|
||||
let mut services = BTreeMap::<u16, SandboxService>::new();
|
||||
let mut family = None;
|
||||
|
|
@ -743,7 +743,7 @@ fn parse_proc_net_ipv6(value: &str) -> Option<Ipv6Addr> {
|
|||
|
||||
fn push_service(
|
||||
services: &mut BTreeMap<u16, SandboxService>,
|
||||
provider: SandboxProvider,
|
||||
provider: SandboxProviderKind,
|
||||
port: u16,
|
||||
address: String,
|
||||
process: Option<String>,
|
||||
|
|
@ -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<String>, value: String) {
|
||||
|
|
@ -866,7 +866,7 @@ async fn reconnect_daytona_sandbox(
|
|||
run_id: &RunId,
|
||||
) -> Result<DaytonaSandbox, Response> {
|
||||
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]
|
||||
|
|
|
|||
247
lib/crates/fabro-server/src/server/handler/sandboxes.rs
Normal file
247
lib/crates/fabro-server/src/server/handler/sandboxes.rs
Normal file
|
|
@ -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<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/sandboxes", get(list_sandboxes))
|
||||
.route("/sandboxes/{id}", get(retrieve_sandbox))
|
||||
}
|
||||
|
||||
async fn list_sandboxes(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth: RequiredRunManagementActor,
|
||||
) -> Json<SandboxListResponse> {
|
||||
Json(state.sandbox_provider_registry().list_managed().await)
|
||||
}
|
||||
|
||||
async fn retrieve_sandbox(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
_auth: RequiredRunManagementActor,
|
||||
) -> Result<Json<SandboxInfo>, 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::<Vec<_>>()
|
||||
.join("; ")
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_list(providers: &[SandboxProviderKind]) -> String {
|
||||
providers
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.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<Body> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Arc
|
|||
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(),
|
||||
})
|
||||
}
|
||||
|
|
@ -5215,6 +5218,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
|
|||
github_api_base_url,
|
||||
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(),
|
||||
};
|
||||
let state = build_app_state(config).expect("test app state should build");
|
||||
|
|
@ -11575,7 +11579,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, 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,
|
||||
|
|
|
|||
|
|
@ -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<Box<RegistryFactoryOverride>>,
|
||||
sandbox_provider_registry: Option<SandboxProviderRegistry>,
|
||||
store_bundle: Option<(Arc<Database>, ArtifactStore)>,
|
||||
vault_path: Option<PathBuf>,
|
||||
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<String> + 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")
|
||||
|
|
|
|||
|
|
@ -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<RunProjection> {
|
|||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
lib/crates/fabro-types/src/sandbox_inventory.rs
Normal file
52
lib/crates/fabro-types/src/sandbox_inventory.rs
Normal file
|
|
@ -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<String>,
|
||||
pub state: SandboxState,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub native_state: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub snapshot: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub web_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub working_directory: Option<String>,
|
||||
pub resources: SandboxResources,
|
||||
#[serde(default)]
|
||||
pub network: SandboxNetwork,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
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<SandboxProviderLookupError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SandboxListResponse {
|
||||
pub data: Vec<SandboxInfo>,
|
||||
pub meta: SandboxListMeta,
|
||||
}
|
||||
|
|
@ -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::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Local
|
||||
"local".parse::<SandboxProviderKind>().unwrap(),
|
||||
SandboxProviderKind::Local
|
||||
);
|
||||
assert_eq!(
|
||||
"docker".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Docker
|
||||
"docker".parse::<SandboxProviderKind>().unwrap(),
|
||||
SandboxProviderKind::Docker
|
||||
);
|
||||
assert_eq!(
|
||||
"daytona".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Daytona
|
||||
"daytona".parse::<SandboxProviderKind>().unwrap(),
|
||||
SandboxProviderKind::Daytona
|
||||
);
|
||||
assert_eq!(
|
||||
"LOCAL".parse::<SandboxProvider>().unwrap(),
|
||||
SandboxProvider::Local
|
||||
"LOCAL".parse::<SandboxProviderKind>().unwrap(),
|
||||
SandboxProviderKind::Local
|
||||
);
|
||||
assert!("invalid".parse::<SandboxProvider>().is_err());
|
||||
assert!("invalid".parse::<SandboxProviderKind>().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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ impl EnvironmentProvider {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<EnvironmentProvider> for crate::SandboxProvider {
|
||||
impl From<EnvironmentProvider> for crate::SandboxProviderKind {
|
||||
fn from(value: EnvironmentProvider) -> Self {
|
||||
match value {
|
||||
EnvironmentProvider::Local => Self::Local,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
lib/crates/fabro-types/tests/sandbox_inventory_serde.rs
Normal file
119
lib/crates/fabro-types/tests/sandbox_inventory_serde.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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::<SandboxProvider>(json!("local")).unwrap(),
|
||||
SandboxProvider::Local
|
||||
serde_json::from_value::<SandboxProviderKind>(json!("local")).unwrap(),
|
||||
SandboxProviderKind::Local
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<SandboxProvider>(json!("docker")).unwrap(),
|
||||
SandboxProvider::Docker
|
||||
serde_json::from_value::<SandboxProviderKind>(json!("docker")).unwrap(),
|
||||
SandboxProviderKind::Docker
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<SandboxProvider>(json!("daytona")).unwrap(),
|
||||
SandboxProvider::Daytona
|
||||
serde_json::from_value::<SandboxProviderKind>(json!("daytona")).unwrap(),
|
||||
SandboxProviderKind::Daytona
|
||||
);
|
||||
assert!(serde_json::from_value::<SandboxProvider>(json!("other")).is_err());
|
||||
assert!(serde_json::from_value::<SandboxProviderKind>(json!("other")).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
lib/packages/fabro-api-client/src/api.ts
generated
1
lib/packages/fabro-api-client/src/api.ts
generated
|
|
@ -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';
|
||||
|
|
|
|||
200
lib/packages/fabro-api-client/src/api/sandboxes-api.ts
generated
Normal file
200
lib/packages/fabro-api-client/src/api/sandboxes-api.ts
generated
Normal file
|
|
@ -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<RequestArgs> => {
|
||||
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<RequestArgs> => {
|
||||
// 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<SandboxListResponse>> {
|
||||
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<SandboxInfo>> {
|
||||
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<SandboxListResponse> {
|
||||
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<SandboxInfo> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
77
lib/packages/fabro-api-client/src/models/sandbox-info.ts
generated
Normal file
77
lib/packages/fabro-api-client/src/models/sandbox-info.ts
generated
Normal file
|
|
@ -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;
|
||||
}
|
||||
25
lib/packages/fabro-api-client/src/models/sandbox-list-meta.ts
generated
Normal file
25
lib/packages/fabro-api-client/src/models/sandbox-list-meta.ts
generated
Normal file
|
|
@ -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<SandboxProviderLookupError>;
|
||||
}
|
||||
29
lib/packages/fabro-api-client/src/models/sandbox-list-response.ts
generated
Normal file
29
lib/packages/fabro-api-client/src/models/sandbox-list-response.ts
generated
Normal file
|
|
@ -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<SandboxInfo>;
|
||||
'meta': SandboxListMeta;
|
||||
}
|
||||
27
lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts
generated
Normal file
27
lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts
generated
Normal file
|
|
@ -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];
|
||||
26
lib/packages/fabro-api-client/src/models/sandbox-provider-lookup-error.ts
generated
Normal file
26
lib/packages/fabro-api-client/src/models/sandbox-provider-lookup-error.ts
generated
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue