mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
A run's host sandbox is a managed directory the worker recorded, with Petri's labels, in the host registry inside the run's Petri directory. The server reached it only by designating the directory again: a handle with no record, so no labels and no ownership check, unlike Docker and Daytona where `petri.run` is checked on every attach. The server now observes the run's registry (`HostProvider:: observe_registry`, read and never written, so the worker stays its only writer), resolves the directory to its record by path (`attach_directory`), and runs the same `petri.run` ownership check as on Docker (`OwnedProvider::check`). The handle refuses every lifecycle change, so starting, stopping, and deleting stay the worker's, and a stopped sandbox's retained workspace is usable through it; the access paths therefore return a host handle as attached instead of activating it. `ProviderAccess` carries the storage root the registry is found under; a directory no registry records (a run older than this driver, a caller without a storage root, a pruned Petri directory) is designated again as before, without labels, for the read paths only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
268 lines
8.8 KiB
Rust
268 lines
8.8 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::routing::get;
|
|
use axum::{Json, Router};
|
|
use fabro_types::{SandboxInfo, SandboxListResponse, SandboxProviderKind};
|
|
|
|
use super::super::AppState;
|
|
use crate::error::ApiError;
|
|
use crate::principal_middleware::RequiredRunManagementActor;
|
|
use crate::sandbox_access::SandboxLookupError;
|
|
|
|
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_inventory().list_managed().await)
|
|
}
|
|
|
|
async fn retrieve_sandbox(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
_auth: RequiredRunManagementActor,
|
|
) -> Result<Json<SandboxInfo>, ApiError> {
|
|
state
|
|
.sandbox_inventory()
|
|
.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 std::sync::Arc;
|
|
|
|
use axum::body::{Body, to_bytes};
|
|
use axum::http::{Request, StatusCode};
|
|
use fabro_types::SandboxProviderKind;
|
|
use fabro_types::settings::server::{
|
|
SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings,
|
|
};
|
|
use sandbox_driver::SandboxProvider;
|
|
use serde_json::{Value, json};
|
|
use tower::ServiceExt;
|
|
|
|
use crate::sandbox_access::test_support::{petri_scripted_sandbox, scripted_provider};
|
|
use crate::sandbox_access::{ProviderAccess, SandboxInventory};
|
|
use crate::test_support::{TestAppStateBuilder, build_test_router};
|
|
|
|
fn app_with_inventory(inventory: SandboxInventory) -> axum::Router {
|
|
let state = TestAppStateBuilder::new()
|
|
.sandbox_inventory(inventory)
|
|
.build();
|
|
build_test_router(state)
|
|
}
|
|
|
|
/// A provider of `kind` holding the sandboxes Petri created for a run,
|
|
/// `ids`.
|
|
fn provider(kind: &SandboxProviderKind, ids: &[&str]) -> Arc<dyn SandboxProvider> {
|
|
scripted_provider(
|
|
kind.as_str(),
|
|
ids.iter()
|
|
.map(|id| petri_scripted_sandbox(id, "01HY0000000000000000000000"))
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
/// A plugin kind whose executable does not exist, so every lookup fails
|
|
/// to connect.
|
|
fn with_unreachable_plugin(inventory: SandboxInventory, name: &str) -> SandboxInventory {
|
|
let kind = SandboxProviderKind::try_new(name).expect("valid kind");
|
|
let mut providers = ServerSandboxProvidersSettings::default();
|
|
providers
|
|
.entries
|
|
.insert(kind.clone(), ServerSandboxProviderSettings {
|
|
enabled: true,
|
|
plugin: Some(SandboxPluginSettings {
|
|
path: Some(format!("/nonexistent/sandbox-driver-{name}")),
|
|
dev: true,
|
|
..SandboxPluginSettings::default()
|
|
}),
|
|
});
|
|
inventory.with_lazy(kind, ProviderAccess {
|
|
providers,
|
|
daytona: None,
|
|
storage_root: None,
|
|
})
|
|
}
|
|
|
|
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 app = app_with_inventory(SandboxInventory::empty().with_connected(
|
|
SandboxProviderKind::DOCKER,
|
|
provider(&SandboxProviderKind::DOCKER, &["docker-native-id"]),
|
|
));
|
|
|
|
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]["status"]["id"], "docker-native-id");
|
|
assert_eq!(body["data"][0]["provider"], "docker");
|
|
assert_eq!(body["data"][0]["status"]["state"], "running");
|
|
assert_eq!(body["meta"]["provider_errors"], json!([]));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retrieve_searches_all_configured_providers() {
|
|
let app = app_with_inventory(
|
|
SandboxInventory::empty()
|
|
.with_connected(
|
|
SandboxProviderKind::DOCKER,
|
|
provider(&SandboxProviderKind::DOCKER, &[]),
|
|
)
|
|
.with_connected(
|
|
SandboxProviderKind::DAYTONA,
|
|
provider(&SandboxProviderKind::DAYTONA, &["native-id"]),
|
|
),
|
|
);
|
|
|
|
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["status"]["id"], "native-id");
|
|
assert_eq!(body["provider"], "daytona");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn no_matching_sandbox_returns_404() {
|
|
let app = app_with_inventory(
|
|
SandboxInventory::empty()
|
|
.with_connected(
|
|
SandboxProviderKind::DOCKER,
|
|
provider(&SandboxProviderKind::DOCKER, &[]),
|
|
)
|
|
.with_connected(
|
|
SandboxProviderKind::DAYTONA,
|
|
provider(&SandboxProviderKind::DAYTONA, &[]),
|
|
),
|
|
);
|
|
|
|
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_inventory(
|
|
SandboxInventory::empty()
|
|
.with_connected(
|
|
SandboxProviderKind::DOCKER,
|
|
provider(&SandboxProviderKind::DOCKER, &["same-id"]),
|
|
)
|
|
.with_connected(
|
|
SandboxProviderKind::DAYTONA,
|
|
provider(&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_inventory(with_unreachable_plugin(
|
|
SandboxInventory::empty().with_connected(
|
|
SandboxProviderKind::DOCKER,
|
|
provider(&SandboxProviderKind::DOCKER, &[]),
|
|
),
|
|
"e2b",
|
|
));
|
|
|
|
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("e2b: Failed to connect to the e2b provider")
|
|
);
|
|
}
|
|
}
|