mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-24 00:51:19 +00:00
Closes the sandbox-driver adoption: any provider a sandbox-driver plugin executable serves can now host a fabro run, and fabro's own bundled providers can be served the same way. - `SandboxSpec::Plugin` builds a normalized driver spec from the environment (image or Dockerfile source, or a provider-managed directory; resources; network policy; labels; env) and lays fabro's repository checkout out inside the provider's working directory. The layout is recorded on the run through the new `workspace_layout` trait method. - Plugin settings on a bundled kind (`[server.sandbox.providers.docker] path = ...`) serve that kind out of process through the driver's executable; the config layer no longer rejects them. - `ProviderAccess` carries the server's provider settings and the vault's Daytona credentials to every reconnect: run resume, sandbox details, terminals, previews, and the worker's start path. The worker receives the settings through `StartServices`. No "plugin not wired" errors remain. - The CLI worker requires GitHub credentials only when a repository will be cloned; a `none` target on a clone-based provider creates an empty workspace and needs none. - fabro-db tracks its migrations directory so a new migration file recompiles the crate; the environment provider migration had been silently missing from stale builds. Environment store 500s now log their cause. - The CLI workflow scenarios run against `host-plugin` (the driver's Host executable under the non-bundled `host` kind) and `docker-plugin` (the bundled `docker` kind served over stdio), each on an isolated server, printing the server log on failure. A live Daytona gate runs the native git clone over the JSON-RPC wire. A new CI job runs the plugin scenarios and the driver-backed Docker integration tests with the plugin executables built. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
284 lines
9.4 KiB
Rust
284 lines
9.4 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::http::HeaderMap;
|
|
use fabro_environment::{Environment, EnvironmentDraft, EnvironmentId, EnvironmentStoreError};
|
|
use fabro_types::SandboxProviderKind;
|
|
use fabro_types::settings::InterpString;
|
|
use fabro_types::settings::run::{
|
|
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
|
|
EnvironmentNetworkSettings, EnvironmentResourcesSettings, EnvironmentSettings,
|
|
};
|
|
use fabro_util::error::{collect_chain, render_with_causes};
|
|
use serde::de::IgnoredAny;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::super::{
|
|
ApiError, AppState, IntoResponse, Json, Path, RequiredUser, Response, Router, State,
|
|
StatusCode, get,
|
|
};
|
|
use super::{json_with_etag_response, parse_required_if_match};
|
|
|
|
#[derive(Serialize)]
|
|
struct EnvironmentListResponse {
|
|
data: Vec<Environment>,
|
|
meta: EnvironmentListMeta,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct EnvironmentListMeta {
|
|
total: usize,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct CreateEnvironmentRequest {
|
|
id: EnvironmentId,
|
|
provider: SandboxProviderKind,
|
|
cwd: Option<String>,
|
|
image: ApiEnvironmentImageSettings,
|
|
resources: EnvironmentResourcesSettings,
|
|
network: EnvironmentNetworkSettings,
|
|
lifecycle: EnvironmentLifecycleSettings,
|
|
labels: HashMap<String, String>,
|
|
env: HashMap<String, InterpString>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct ReplaceEnvironmentRequest {
|
|
provider: SandboxProviderKind,
|
|
cwd: Option<String>,
|
|
image: ApiEnvironmentImageSettings,
|
|
resources: EnvironmentResourcesSettings,
|
|
network: EnvironmentNetworkSettings,
|
|
lifecycle: EnvironmentLifecycleSettings,
|
|
labels: HashMap<String, String>,
|
|
env: HashMap<String, InterpString>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct ApiEnvironmentImageSettings {
|
|
docker: Option<String>,
|
|
dockerfile: Option<ApiDockerfileSource>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
|
enum ApiDockerfileSource {
|
|
Inline {
|
|
value: String,
|
|
},
|
|
// Recognized so the handler can return a 422 with bespoke guidance.
|
|
// The `path` payload is parsed and discarded — never read from disk.
|
|
Path {
|
|
#[serde(rename = "path")]
|
|
_path: IgnoredAny,
|
|
},
|
|
}
|
|
|
|
impl CreateEnvironmentRequest {
|
|
fn into_draft(self) -> Result<EnvironmentDraft, ApiError> {
|
|
Ok(EnvironmentDraft {
|
|
id: self.id,
|
|
settings: EnvironmentSettings {
|
|
provider: self.provider,
|
|
cwd: self.cwd,
|
|
image: self.image.into_settings()?,
|
|
resources: self.resources,
|
|
network: self.network,
|
|
lifecycle: self.lifecycle,
|
|
labels: self.labels,
|
|
env: self.env,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ReplaceEnvironmentRequest {
|
|
fn into_settings(self) -> Result<EnvironmentSettings, ApiError> {
|
|
Ok(EnvironmentSettings {
|
|
provider: self.provider,
|
|
cwd: self.cwd,
|
|
image: self.image.into_settings()?,
|
|
resources: self.resources,
|
|
network: self.network,
|
|
lifecycle: self.lifecycle,
|
|
labels: self.labels,
|
|
env: self.env,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ApiEnvironmentImageSettings {
|
|
fn into_settings(self) -> Result<EnvironmentImageSettings, ApiError> {
|
|
Ok(EnvironmentImageSettings {
|
|
docker: self.docker,
|
|
dockerfile: self
|
|
.dockerfile
|
|
.map(ApiDockerfileSource::into_settings)
|
|
.transpose()?,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ApiDockerfileSource {
|
|
fn into_settings(self) -> Result<DockerfileSource, ApiError> {
|
|
match self {
|
|
Self::Inline { value } => Ok(DockerfileSource::Inline(value)),
|
|
Self::Path { .. } => Err(ApiError::new(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"Dockerfile path sources are not supported by the environments REST API; use inline Dockerfile content",
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route(
|
|
"/environments",
|
|
get(list_environments).post(create_environment),
|
|
)
|
|
.route(
|
|
"/environments/{id}",
|
|
get(get_environment)
|
|
.put(replace_environment)
|
|
.delete(delete_environment),
|
|
)
|
|
}
|
|
|
|
async fn list_environments(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
|
|
let data = state.environment_store().list();
|
|
let total = data.len();
|
|
(
|
|
StatusCode::OK,
|
|
Json(EnvironmentListResponse {
|
|
data,
|
|
meta: EnvironmentListMeta { total },
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn create_environment(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Json(request): Json<CreateEnvironmentRequest>,
|
|
) -> Result<Response, ApiError> {
|
|
let environment = state
|
|
.environment_store()
|
|
.create(request.into_draft()?)
|
|
.await?;
|
|
state.refresh_manifest_run_settings_from_environment_catalog();
|
|
Ok((StatusCode::CREATED, Json(environment)).into_response())
|
|
}
|
|
|
|
async fn get_environment(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Response, ApiError> {
|
|
let id = parse_path_id(id)?;
|
|
match state.environment_store().get(&id) {
|
|
Some(environment) => Ok(environment_with_etag_response(StatusCode::OK, environment)),
|
|
None => Err(ApiError::not_found(format!("environment not found: {id}"))),
|
|
}
|
|
}
|
|
|
|
async fn replace_environment(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<String>,
|
|
Json(request): Json<ReplaceEnvironmentRequest>,
|
|
) -> Result<Response, ApiError> {
|
|
let id = parse_path_id(id)?;
|
|
let expected = parse_required_if_match(&headers, "environment", &id)?;
|
|
let environment = state
|
|
.environment_store()
|
|
.replace(&id, &expected, request.into_settings()?)
|
|
.await?;
|
|
state.refresh_manifest_run_settings_from_environment_catalog();
|
|
Ok(environment_with_etag_response(StatusCode::OK, environment))
|
|
}
|
|
|
|
async fn delete_environment(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<String>,
|
|
) -> Result<Response, ApiError> {
|
|
let id = parse_path_id(id)?;
|
|
let expected = parse_required_if_match(&headers, "environment", &id)?;
|
|
if state
|
|
.automation_store()
|
|
.references_environment(id.as_str())
|
|
.await?
|
|
{
|
|
return Err(ApiError::with_code(
|
|
StatusCode::CONFLICT,
|
|
format!("environment is used by an automation: {id}"),
|
|
"environment_in_use",
|
|
));
|
|
}
|
|
state.environment_store().delete(&id, &expected).await?;
|
|
state.refresh_manifest_run_settings_from_environment_catalog();
|
|
Ok(StatusCode::NO_CONTENT.into_response())
|
|
}
|
|
|
|
fn parse_path_id(id: String) -> Result<EnvironmentId, ApiError> {
|
|
EnvironmentId::new(id)
|
|
.map_err(|err| ApiError::bad_request(format!("invalid environment id: {err}")))
|
|
}
|
|
|
|
fn environment_with_etag_response(status: StatusCode, environment: Environment) -> Response {
|
|
let revision = environment.revision.clone();
|
|
json_with_etag_response(status, "environment", &revision, environment)
|
|
}
|
|
|
|
impl From<EnvironmentStoreError> for ApiError {
|
|
fn from(err: EnvironmentStoreError) -> Self {
|
|
match err {
|
|
EnvironmentStoreError::NotFound { id } => {
|
|
Self::not_found(format!("environment not found: {id}"))
|
|
}
|
|
EnvironmentStoreError::AlreadyExists { id } => Self::new(
|
|
StatusCode::CONFLICT,
|
|
format!("environment already exists: {id}"),
|
|
),
|
|
EnvironmentStoreError::StaleRevision { id, .. } => Self::new(
|
|
StatusCode::CONFLICT,
|
|
format!("environment revision is stale: {id}"),
|
|
),
|
|
EnvironmentStoreError::Reserved { id } => Self::new(
|
|
StatusCode::CONFLICT,
|
|
format!("environment is reserved and cannot be modified: {id}"),
|
|
),
|
|
EnvironmentStoreError::Validation { source } => {
|
|
Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string())
|
|
}
|
|
EnvironmentStoreError::InvalidFilename { .. }
|
|
| EnvironmentStoreError::InvalidRevision { .. }
|
|
| EnvironmentStoreError::Parse { .. }
|
|
| EnvironmentStoreError::InvalidUtf8 { .. }
|
|
| EnvironmentStoreError::Serialize { .. }
|
|
| EnvironmentStoreError::JsonEncode { .. }
|
|
| EnvironmentStoreError::JsonDecode { .. }
|
|
| EnvironmentStoreError::Db { .. }
|
|
| EnvironmentStoreError::RowCountOverflow { .. }
|
|
| EnvironmentStoreError::Io { .. } => {
|
|
// The response hides the cause; the log keeps it.
|
|
tracing::error!(
|
|
error = %render_with_causes(&err.to_string(), &collect_chain(&err)),
|
|
"environment store operation failed"
|
|
);
|
|
Self::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"environment store operation failed",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|