fabro/lib/apps/fabro-server/src/server/handler/environments.rs
Bryan Helmkamp 030e653abf
Run plugin providers end to end and gate them in CI
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>
2026-09-09 19:54:57 -06:00

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",
)
}
}
}
}