diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 688f0adbf..a78a6cc10 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -3,7 +3,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { EnvironmentApiDockerfileSourceInlineTypeEnum, EnvironmentNetworkMode, - EnvironmentProvider, } from "@qltysh/fabro-api-client"; import type { CreateEnvironmentRequest, @@ -15,6 +14,7 @@ import type { ReplaceEnvironmentRequest, } from "@qltysh/fabro-api-client"; +import { DOCKER_PROVIDER, isCloneBasedProvider } from "../lib/environment-providers"; import { Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { @@ -25,11 +25,15 @@ import { } from "./key-value-editor"; // Parse the `provider` query param used by the create flow into a creatable -// provider, defaulting to Docker for anything unexpected. -export function parseCreatableProvider(value: string | null): EnvironmentProvider { - return value === EnvironmentProvider.DAYTONA - ? EnvironmentProvider.DAYTONA - : EnvironmentProvider.DOCKER; +// provider, defaulting to Docker for anything that cannot back a managed +// environment. Kind names are validated server-side on create. +const PROVIDER_KIND_PATTERN = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/; + +export function parseCreatableProvider(value: string | null): string { + if (value && PROVIDER_KIND_PATTERN.test(value) && isCloneBasedProvider(value)) { + return value; + } + return DOCKER_PROVIDER; } // Environment ids are server-managed file names: lowercase, digits, hyphens. @@ -49,7 +53,7 @@ type ImageSource = "image" | "dockerfile"; export interface EnvironmentFormValues { id: string; - provider: EnvironmentProvider; + provider: string; imageSource: ImageSource; dockerRef: string; dockerfile: string; @@ -69,7 +73,7 @@ export interface EnvironmentFormValues { export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { id: "", - provider: EnvironmentProvider.DOCKER, + provider: DOCKER_PROVIDER, imageSource: "image", dockerRef: "", dockerfile: "", diff --git a/apps/fabro-web/app/lib/environment-providers.ts b/apps/fabro-web/app/lib/environment-providers.ts index 8002cf6a4..300fe54cb 100644 --- a/apps/fabro-web/app/lib/environment-providers.ts +++ b/apps/fabro-web/app/lib/environment-providers.ts @@ -1,17 +1,44 @@ -import { EnvironmentProvider, type Environment } from "@qltysh/fabro-api-client"; +import type { Environment, ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; -// Providers a managed environment can be created with. `local` is a reserved, -// in-memory environment, never a managed-environment provider, so it is never -// offered. The provider is fixed at creation time and cannot be changed. -export const CREATABLE_PROVIDERS = [ - EnvironmentProvider.DOCKER, - EnvironmentProvider.DAYTONA, -] as const; +// The providers linked into the server. Any other provider kind names a +// sandbox-driver plugin the operator configured under +// `server.sandbox.providers.`. +export const LOCAL_PROVIDER = "local"; +export const DOCKER_PROVIDER = "docker"; +export const DAYTONA_PROVIDER = "daytona"; + +export const BUNDLED_PROVIDERS = [LOCAL_PROVIDER, DOCKER_PROVIDER, DAYTONA_PROVIDER] as const; + +export type ProviderSettingsMap = { [kind: string]: ServerSandboxProviderSettings }; + +// `local` runs in the caller's directory and never clones. Every other +// provider owns an isolated workspace that Fabro clones into. +export function isCloneBasedProvider(provider: string): boolean { + return provider !== LOCAL_PROVIDER; +} // Whether a server-managed environment can back Git-targeted work such as -// automations: only the clone-based (creatable) providers qualify. +// automations: only clone-based providers qualify. export function isCloneBasedEnvironment(environment: Environment): boolean { - return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider); + return isCloneBasedProvider(environment.provider); +} + +// Providers a managed environment can be created with: every enabled +// clone-based provider. `local` is a reserved, in-memory environment, never a +// managed-environment provider, so it is never offered. +export function creatableProviders(providers: ProviderSettingsMap): string[] { + return Object.keys(providers) + .filter((kind) => isCloneBasedProvider(kind) && providers[kind]?.enabled) + .sort(compareProviderKinds); +} + +// Bundled kinds first, in their canonical order, then plugins alphabetically. +export function compareProviderKinds(left: string, right: string): number { + const rank = (kind: string) => { + const index = (BUNDLED_PROVIDERS as readonly string[]).indexOf(kind); + return index === -1 ? BUNDLED_PROVIDERS.length : index; + }; + return rank(left) - rank(right) || left.localeCompare(right); } export function providerLabel(provider: string): string { diff --git a/apps/fabro-web/app/routes/settings-environments.tsx b/apps/fabro-web/app/routes/settings-environments.tsx index b3059ab52..fdd13aee4 100644 --- a/apps/fabro-web/app/routes/settings-environments.tsx +++ b/apps/fabro-web/app/routes/settings-environments.tsx @@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client"; import { ApiError, apiData, environmentsApi } from "../lib/api-client"; import { useEnvironments, useServerSettings } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; -import { CREATABLE_PROVIDERS, providerLabel } from "../lib/environment-providers"; +import { creatableProviders, providerLabel } from "../lib/environment-providers"; import { Badge, Muted, @@ -67,9 +67,7 @@ const NEW_BUTTON_CLASS = // environment's lifetime. `local` is never offered (it's reserved/in-memory). function NewEnvironmentMenu() { const { data } = useServerSettings(); - const providers = data - ? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled) - : []; + const providers = data ? creatableProviders(data.server.sandbox.providers) : []; if (providers.length === 0) { return ( diff --git a/apps/fabro-web/app/routes/settings-sandboxes.tsx b/apps/fabro-web/app/routes/settings-sandboxes.tsx index d51197c73..577f81866 100644 --- a/apps/fabro-web/app/routes/settings-sandboxes.tsx +++ b/apps/fabro-web/app/routes/settings-sandboxes.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { Link } from "react-router"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ComputerDesktopIcon } from "@heroicons/react/24/outline"; -import type { ServerSandboxProvidersSettings } from "@qltysh/fabro-api-client"; +import type { ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; import { useServerSettings } from "../lib/queries"; import { Dot, @@ -12,24 +12,56 @@ import { SettingsPageIntro, } from "../components/settings-panel"; import { plural } from "../lib/plural"; +import { + DAYTONA_PROVIDER, + DOCKER_PROVIDER, + LOCAL_PROVIDER, + compareProviderKinds, + providerLabel, + type ProviderSettingsMap, +} from "../lib/environment-providers"; export function meta() { return [{ title: "Sandboxes — Fabro" }]; } -type SandboxProviderId = "local" | "docker" | "daytona"; - type SandboxProvider = { - id: SandboxProviderId; + id: string; name: string; description: string; enabled: boolean; + bundled: boolean; secretName?: string; }; const DESCRIPTION = "Runtime environments where workflow stages execute. Configured via settings.toml."; +// Display copy for the providers linked into the server. Any other kind is a +// sandbox-driver plugin configured under `server.sandbox.providers.`. +const BUNDLED_PROVIDER_COPY: Record> = { + [LOCAL_PROVIDER]: { + name: "Local", + description: "Run stages directly on the Fabro host.", + }, + [DOCKER_PROVIDER]: { + name: "Docker", + description: "Run stages in isolated Docker containers on the host daemon.", + }, + [DAYTONA_PROVIDER]: { + name: "Daytona", + description: "Run stages in cloud sandboxes managed by Daytona.", + secretName: "DAYTONA_API_KEY", + }, +}; + +function pluginDescription(settings: ServerSandboxProviderSettings): string { + const path = settings.plugin?.path; + return path + ? `Sandbox plugin executable at ${path}.` + : "Sandbox plugin executable resolved from PATH."; +} + export default function SettingsSandboxes() { const query = useServerSettings(); const settings = query.data; @@ -42,29 +74,24 @@ export default function SettingsSandboxes() { ); } -function ProvidersPanel({ settings }: { settings: ServerSandboxProvidersSettings }) { +function ProvidersPanel({ settings }: { settings: ProviderSettingsMap }) { const providers: SandboxProvider[] = useMemo( - () => [ - { - id: "local", - name: "Local", - description: "Run stages directly on the Fabro host.", - enabled: settings.local.enabled, - }, - { - id: "docker", - name: "Docker", - description: "Run stages in isolated Docker containers on the host daemon.", - enabled: settings.docker.enabled, - }, - { - id: "daytona", - name: "Daytona", - description: "Run stages in cloud sandboxes managed by Daytona.", - enabled: settings.daytona.enabled, - secretName: "DAYTONA_API_KEY", - }, - ], + () => + Object.keys(settings) + .sort(compareProviderKinds) + .map((id) => { + const entry = settings[id]; + const copy = BUNDLED_PROVIDER_COPY[id]; + return copy + ? { id, enabled: entry.enabled, bundled: true, ...copy } + : { + id, + enabled: entry.enabled, + bundled: false, + name: providerLabel(id), + description: pluginDescription(entry), + }; + }), [settings], ); @@ -138,7 +165,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) { "grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong"; const dim = provider.enabled ? "" : "opacity-60"; - if (provider.id === "local") { + if (provider.id === LOCAL_PROVIDER) { return (