diff --git a/Cargo.lock b/Cargo.lock
index f5473abc4..5b3704880 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2536,16 +2536,22 @@ dependencies = [
name = "fabro-environment"
version = "0.281.0-nightly.0"
dependencies = [
+ "anyhow",
+ "chrono",
"fabro-config",
+ "fabro-db",
"fabro-types",
"hex",
"serde",
+ "serde_json",
"sha2 0.10.9",
+ "sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.8.23",
"toml_edit",
+ "tracing",
]
[[package]]
@@ -2624,6 +2630,8 @@ dependencies = [
"anyhow",
"base64",
"fabro-config",
+ "fabro-db",
+ "fabro-environment",
"fabro-static",
"fabro-types",
"fabro-util",
@@ -3088,11 +3096,12 @@ dependencies = [
name = "fabro-test"
version = "0.281.0-nightly.0"
dependencies = [
+ "anyhow",
"assert_cmd",
"axum",
"fabro-config",
- "fabro-environment",
"fabro-http",
+ "fabro-install",
"fabro-proc",
"fabro-static",
"fabro-types",
diff --git a/docs/public/execution/environments.mdx b/docs/public/execution/environments.mdx
index 44c1a8bb1..f88d9bdd4 100644
--- a/docs/public/execution/environments.mdx
+++ b/docs/public/execution/environments.mdx
@@ -11,7 +11,7 @@ Fabro separates **environments** from **sandboxes**:
Older pre-v1.0 config files that still use `[run.sandbox]` are temporarily auto-migrated when Fabro loads them from disk. Fabro writes a sibling `*.legacy-sandbox-migration.bak` file, rewrites the config to `[run.environment]` plus an environment definition, and then continues startup.
-Similarly, `[environments.*]` tables in the server's active `settings.toml` are auto-migrated on startup: each entry is extracted into a sibling `environments/.toml` file, with a `.settings-environments-migration.bak` backup written first.
+Similarly, `[environments.*]` tables in the server's active `settings.toml` are auto-migrated on startup. Existing sibling `environments/*.toml` files are then treated as a legacy import source: Fabro imports missing environment IDs into SQLite once and renames the directory to `environments.imported-.bak`.
These compatibility rewrites only handle direct field mappings. Unsupported legacy fields fail with a migration message that lists the keys to edit manually. The rewrite paths will be removed before v1.0.
@@ -23,45 +23,76 @@ Runs select environments by slug:
id = "fabro-dev"
```
-Environments are server-managed. The server keeps one TOML file per environment in an `environments/` directory next to its `settings.toml`, seeded on first startup with built-in `default`, `local`, `docker`, and `daytona` environments. Manage them by editing those files or through the `/api/v1/environments` REST API. Workflow and project TOML can additionally define `[environments.]` catalog entries that merge with the server catalog through the normal settings precedence. The built-in default is `default`, a Docker environment using `buildpack-deps:noble`.
+Server-managed environments are stored in the server SQLite database. Manage them through the web UI or the `/api/v1/environments` REST API. Install seeds `default` as an ordinary persisted environment; users can replace or delete it. `local` is reserved, synthetic, and unpersisted: it appears only when the local sandbox provider is enabled, and it cannot be created, replaced, or deleted through the environments API. Workflow and project TOML can additionally define `[environments.]` catalog entries that merge with the server catalog through the normal settings precedence.
-## Defining environments
+## Defining Server Environments
-Each server-managed environment is a file whose name is its slug:
+Create server-managed environments through the REST API. Stored environments use inline Dockerfile content; local Dockerfile paths are rejected by the API because the server cannot safely resolve client-side paths.
-```toml title="environments/fabro-dev.toml"
-provider = "daytona" # local | docker | daytona
-
-[image]
-dockerfile = { path = "Dockerfile" }
-
-[resources]
-cpu = 8
-memory = "16GB"
-disk = "20GB"
-
-[network]
-mode = "cidr_allow_list" # allow_all | block | cidr_allow_list
-allow = ["10.0.0.0/8"]
-
-[lifecycle]
-preserve = false
-stop_on_terminal = true
-auto_stop = "30m"
-
-[labels]
-repo = "fabro-sh/fabro"
-
-[env]
-NODE_ENV = "development"
+```json title="POST /api/v1/environments"
+{
+ "id": "fabro-dev",
+ "provider": "daytona",
+ "cwd": null,
+ "image": {
+ "docker": null,
+ "dockerfile": {
+ "type": "inline",
+ "value": "FROM buildpack-deps:noble\n"
+ }
+ },
+ "resources": {
+ "cpu": 8,
+ "memory": "16GB",
+ "disk": "20GB"
+ },
+ "network": {
+ "mode": "cidr_allow_list",
+ "allow": ["10.0.0.0/8"]
+ },
+ "lifecycle": {
+ "preserve": false,
+ "stop_on_terminal": true,
+ "auto_stop": "30m"
+ },
+ "labels": {
+ "repo": "fabro-sh/fabro"
+ },
+ "env": {
+ "NODE_ENV": "development"
+ }
+}
```
-Server-managed local environments can also set `cwd`, an optional runtime
+Server-managed local-provider environments can also set `cwd`, an optional runtime
command working directory:
-```toml title="environments/host.toml"
-provider = "local"
-cwd = "/srv/fabro/workspaces/team-a"
+```json title="POST /api/v1/environments"
+{
+ "id": "host",
+ "provider": "local",
+ "cwd": "/srv/fabro/workspaces/team-a",
+ "image": {
+ "docker": null,
+ "dockerfile": null
+ },
+ "resources": {
+ "cpu": null,
+ "memory": null,
+ "disk": null
+ },
+ "network": {
+ "mode": "allow_all",
+ "allow": []
+ },
+ "lifecycle": {
+ "preserve": false,
+ "stop_on_terminal": true,
+ "auto_stop": null
+ },
+ "labels": {},
+ "env": {}
+}
```
`cwd` is owned by the server environment and is only honored by the `local`
@@ -69,7 +100,7 @@ provider. It is not a replacement for `run.working_dir`. Docker and Daytona
ignore `cwd` and report a preflight warning because those clone-based providers
own their workspace layout. Workflow, project, user, and direct-run
`[environments.]` catalogs cannot set `cwd`; configure it in the
-server-managed environment file or through the environments API.
+server-managed environment through the environments API.
The same fields nest under `[environments.]` when defined in workflow or project TOML instead:
@@ -131,25 +162,39 @@ fabro server start --environment default
`--preserve-sandbox` still controls the concrete runtime instance lifecycle for a run. Runtime commands such as `fabro sandbox ssh` keep the word "sandbox" because they operate on an already-created runtime instance.
-## Built-in environments
+## Seeded Environments
-The server seeds four built-in environments on first startup: `default`, `local`, `docker`, and `daytona`. Missing files are re-seeded, so editing a seeded file customizes it while deleting it restores the built-in definition on the next restart. The seeded default:
+Install seeds a `default` environment into SQLite. It is a normal persisted environment, so deleting it removes the default run target until you recreate it. The standard Docker default is:
-```toml title="environments/default.toml"
-provider = "docker"
-
-[image]
-docker = "buildpack-deps:noble"
-
-[resources]
-cpu = 2
-memory = "4GB"
-
-[lifecycle]
-preserve = false
-stop_on_terminal = true
+```json title="GET /api/v1/environments/default"
+{
+ "id": "default",
+ "provider": "docker",
+ "image": {
+ "docker": "buildpack-deps:noble",
+ "dockerfile": null
+ },
+ "resources": {
+ "cpu": 2,
+ "memory": "4GB",
+ "disk": null
+ },
+ "network": {
+ "mode": "allow_all",
+ "allow": []
+ },
+ "lifecycle": {
+ "preserve": false,
+ "stop_on_terminal": true,
+ "auto_stop": null
+ },
+ "labels": {},
+ "env": {}
+}
```
+`local` is not stored in SQLite. It is synthesized at runtime when the local sandbox provider is enabled.
+
## Provider mappings
| Environment field | Local | Docker | Daytona |
@@ -171,10 +216,7 @@ stop_on_terminal = true
`local` runs tools directly in the resolved working directory. It offers no filesystem or network isolation, so use it only for trusted workflows.
-```toml title="environments/host.toml"
-provider = "local"
-cwd = "/srv/fabro/workspaces/team-a"
-```
+Create a server-managed local-provider environment through the environments API when you need a host `cwd`.
When `cwd` is set, local runs execute commands from that absolute server-side
path. When it is unset, Fabro keeps same-host compatibility by using the
@@ -188,17 +230,18 @@ Fabro hard-errors if a local environment asks for blocked or CIDR-restricted net
Docker runs tools inside a container created from `image.docker`. Docker is the built-in default provider.
-```toml title="environments/ci.toml"
+```toml title="workflow.toml"
+[environments.ci]
provider = "docker"
-[image]
+[environments.ci.image]
docker = "buildpack-deps:noble"
-[resources]
+[environments.ci.resources]
cpu = 2
memory = "4GB"
-[network]
+[environments.ci.network]
mode = "block"
```
@@ -208,21 +251,22 @@ Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fa
Daytona runs tools in a cloud sandbox. Without `image.dockerfile`, Fabro uses Daytona's built-in `daytona-medium` snapshot. With `image.dockerfile`, Fabro computes a deterministic internal snapshot name from the Dockerfile, resource hints, a single-tenant scope, and the Daytona API key.
-```toml title="environments/cloud.toml"
+```toml title="workflow.toml"
+[environments.cloud]
provider = "daytona"
-[image]
+[environments.cloud.image]
dockerfile = { path = "Dockerfile" }
-[resources]
+[environments.cloud.resources]
cpu = 4
memory = "8GB"
disk = "20GB"
-[lifecycle]
+[environments.cloud.lifecycle]
auto_stop = "30m"
-[network]
+[environments.cloud.network]
mode = "cidr_allow_list"
allow = ["208.80.154.232/32", "10.0.0.0/8"]
```
diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs
index 4c40e3dad..d5c2145e4 100644
--- a/lib/crates/fabro-cli/src/commands/install.rs
+++ b/lib/crates/fabro-cli/src/commands/install.rs
@@ -31,15 +31,14 @@ use fabro_install::{
GITHUB_APP_VAULT_KEYS, GITHUB_INSTALL_SECRET_KEYS, InstallListenConfig, InstallPersistencePlan,
PendingDevTokenWrite, PendingSettingsWrite, VaultSecretWrite,
merge_server_settings as merge_server_settings_impl, prepare_dev_token_write_for_install,
- restore_optional_file, rollback_dev_token_write, write_github_app_settings,
- write_token_settings,
+ restore_optional_file, rollback_dev_token_write, seed_environments_in_storage,
+ write_github_app_settings, write_token_settings,
};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, CredentialRef, ProviderId};
use fabro_server::serve;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
-use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::settings::server::ServerAuthMethod;
use fabro_types::settings::validate_public_url_with_label;
use fabro_util::printer::Printer;
@@ -2014,16 +2013,11 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
)
.await?;
- // Seed the default environment next to the settings file. The server never
- // seeds on startup, so install is the only place the default is written;
- // existing files are preserved, so re-running install never clobbers edits.
- let environment_dir = config_path
- .parent()
- .unwrap_or_else(|| Path::new("."))
- .join("environments");
- if let Err(err) =
- fabro_environment::seed_default_environment(&environment_dir, EnvironmentProvider::Docker)
- {
+ // Seed the default environment in SQLite. The server never seeds on
+ // startup, so install is the only place the default is written; existing
+ // rows are preserved, so re-running install never clobbers edits.
+ let environment_seed_result = seed_environments_in_storage(&storage_dir).await;
+ if let Err(err) = environment_seed_result {
fabro_util::printerr!(
printer,
" {} Failed to seed default environment: {err}",
diff --git a/lib/crates/fabro-db/migrations/2026063002_environments.sql b/lib/crates/fabro-db/migrations/2026063002_environments.sql
new file mode 100644
index 000000000..5219d6509
--- /dev/null
+++ b/lib/crates/fabro-db/migrations/2026063002_environments.sql
@@ -0,0 +1,31 @@
+CREATE TABLE environments (
+ id TEXT PRIMARY KEY NOT NULL,
+ revision TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ cwd TEXT,
+ image_docker TEXT,
+ image_dockerfile_inline TEXT,
+ resources_cpu INTEGER,
+ resources_memory TEXT,
+ resources_disk TEXT,
+ network_mode TEXT NOT NULL,
+ network_allow_json TEXT NOT NULL DEFAULT '[]',
+ lifecycle_preserve INTEGER NOT NULL,
+ lifecycle_stop_on_terminal INTEGER NOT NULL,
+ lifecycle_auto_stop TEXT,
+ labels_json TEXT NOT NULL DEFAULT '{}',
+ env_json TEXT NOT NULL DEFAULT '{}',
+ CHECK (length(id) BETWEEN 1 AND 63),
+ CHECK (substr(id, 1, 1) GLOB '[a-z0-9]'),
+ CHECK (id NOT GLOB '*[^a-z0-9-]*'),
+ CHECK (id <> 'local'),
+ CHECK (length(revision) = 64),
+ CHECK (revision NOT GLOB '*[^0-9a-f]*'),
+ CHECK (provider IN ('local', 'docker', 'daytona')),
+ CHECK (network_mode IN ('allow_all', 'block', 'cidr_allow_list')),
+ CHECK (lifecycle_preserve IN (0, 1)),
+ CHECK (lifecycle_stop_on_terminal IN (0, 1)),
+ CHECK (json_valid(network_allow_json)),
+ CHECK (json_valid(labels_json)),
+ CHECK (json_valid(env_json))
+);
diff --git a/lib/crates/fabro-db/tests/sqlite.rs b/lib/crates/fabro-db/tests/sqlite.rs
index e37e05fdf..a030dafa7 100644
--- a/lib/crates/fabro-db/tests/sqlite.rs
+++ b/lib/crates/fabro-db/tests/sqlite.rs
@@ -18,6 +18,13 @@ async fn connect_creates_parent_directory_and_migrate_is_idempotent() -> anyhow:
.await?;
assert_eq!(variable_table_count, 1);
+ let environments_table_count: i64 = sqlx::query_scalar(
+ "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'environments'",
+ )
+ .fetch_one(database.pool())
+ .await?;
+ assert_eq!(environments_table_count, 1);
+
let legacy_import_table_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'legacy_imports'",
)
@@ -34,6 +41,61 @@ async fn connect_creates_parent_directory_and_migrate_is_idempotent() -> anyhow:
Ok(())
}
+#[tokio::test]
+async fn environments_schema_rejects_invalid_rows() -> anyhow::Result<()> {
+ let dir = tempfile::tempdir()?;
+ let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?;
+ database.migrate().await?;
+
+ insert_minimal_environment(database.pool(), "valid", "docker", "allow_all").await?;
+
+ for (id, provider, network_mode) in [
+ ("Bad", "docker", "allow_all"),
+ ("local", "docker", "allow_all"),
+ ("bad-provider", "bogus", "allow_all"),
+ ("bad-network", "docker", "bogus"),
+ ] {
+ let result = insert_minimal_environment(database.pool(), id, provider, network_mode).await;
+ assert!(
+ result.is_err(),
+ "environment row should be rejected: id={id}, provider={provider}, network_mode={network_mode}"
+ );
+ }
+
+ Ok(())
+}
+
+async fn insert_minimal_environment(
+ pool: &fabro_db::DbPool,
+ id: &str,
+ provider: &str,
+ network_mode: &str,
+) -> Result<(), sqlx::Error> {
+ sqlx::query(
+ r"
+ INSERT INTO environments (
+ id,
+ revision,
+ provider,
+ network_mode,
+ lifecycle_preserve,
+ lifecycle_stop_on_terminal
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ ",
+ )
+ .bind(id)
+ .bind("a".repeat(64))
+ .bind(provider)
+ .bind(network_mode)
+ .bind(false)
+ .bind(true)
+ .execute(pool)
+ .await?;
+
+ Ok(())
+}
+
#[tokio::test]
async fn variables_schema_enforces_env_style_names() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
diff --git a/lib/crates/fabro-environment/Cargo.toml b/lib/crates/fabro-environment/Cargo.toml
index 638538df8..ab06a442f 100644
--- a/lib/crates/fabro-environment/Cargo.toml
+++ b/lib/crates/fabro-environment/Cargo.toml
@@ -13,16 +13,22 @@ doctest = false
workspace = true
[dependencies]
+chrono.workspace = true
+fabro-db = { path = "../fabro-db" }
fabro-config = { path = "../fabro-config" }
fabro-types = { path = "../fabro-types" }
hex.workspace = true
serde.workspace = true
+serde_json.workspace = true
sha2.workspace = true
+sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
toml_edit.workspace = true
+tracing.workspace = true
[dev-dependencies]
+anyhow.workspace = true
tempfile = "3"
tokio = { workspace = true, features = ["macros", "test-util"] }
diff --git a/lib/crates/fabro-environment/src/error.rs b/lib/crates/fabro-environment/src/error.rs
index 9adfbcecd..838898a59 100644
--- a/lib/crates/fabro-environment/src/error.rs
+++ b/lib/crates/fabro-environment/src/error.rs
@@ -3,7 +3,7 @@ use std::path::PathBuf;
use toml::de::Error as TomlDeError;
use toml::ser::Error as TomlSerError;
-use crate::{EnvironmentId, EnvironmentRevision};
+use crate::{EnvironmentId, EnvironmentRevision, EnvironmentRevisionParseError};
#[derive(Debug, thiserror::Error)]
pub enum EnvironmentValidationError {
@@ -17,6 +17,10 @@ pub enum EnvironmentValidationError {
#[source]
source: std::io::Error,
},
+ #[error(
+ "Dockerfile path sources are not supported for stored environments; use inline Dockerfile content"
+ )]
+ DockerfilePathUnsupported,
}
#[derive(Debug, thiserror::Error)]
@@ -46,6 +50,12 @@ pub enum EnvironmentStoreError {
#[source]
source: TomlDeError,
},
+ #[error("invalid persisted environment revision for {id}")]
+ InvalidRevision {
+ id: EnvironmentId,
+ #[source]
+ source: EnvironmentRevisionParseError,
+ },
#[error("environment TOML at {path:?} is not UTF-8")]
InvalidUtf8 {
path: PathBuf,
@@ -63,6 +73,25 @@ pub enum EnvironmentStoreError {
#[source]
source: std::io::Error,
},
+ #[error("failed to encode environment JSON for {field}")]
+ JsonEncode {
+ field: &'static str,
+ #[source]
+ source: serde_json::Error,
+ },
+ #[error("failed to decode environment JSON for {field}")]
+ JsonDecode {
+ field: &'static str,
+ #[source]
+ source: serde_json::Error,
+ },
+ #[error("database error")]
+ Db {
+ #[from]
+ source: sqlx::Error,
+ },
+ #[error("environment row count {count} exceeds SQLite integer range")]
+ RowCountOverflow { count: usize },
}
impl EnvironmentStoreError {
@@ -96,8 +125,11 @@ impl EnvironmentStoreError {
Self::Reserved { .. } => "reserved",
Self::Validation { .. } => "validation",
Self::InvalidFilename { .. } => "invalid_filename",
- Self::Parse { .. } | Self::InvalidUtf8 { .. } => "parse",
+ Self::Parse { .. } | Self::InvalidUtf8 { .. } | Self::InvalidRevision { .. } => "parse",
Self::Serialize { .. } => "serialize",
+ Self::JsonEncode { .. } | Self::JsonDecode { .. } => "json",
+ Self::Db { .. } => "db",
+ Self::RowCountOverflow { .. } => "row_count_overflow",
Self::Io { .. } => "io",
}
}
diff --git a/lib/crates/fabro-environment/src/lib.rs b/lib/crates/fabro-environment/src/lib.rs
index 330ea1eda..bd6029bd8 100644
--- a/lib/crates/fabro-environment/src/lib.rs
+++ b/lib/crates/fabro-environment/src/lib.rs
@@ -7,5 +7,6 @@ pub use error::{EnvironmentStoreError, EnvironmentValidationError};
pub use id::{EnvironmentId, EnvironmentRevision, EnvironmentRevisionParseError};
pub use model::{Environment, EnvironmentDraft};
pub use store::{
- EnvironmentStore, seed_default_environment, seed_environments, seeded_catalog_layer,
+ EnvironmentStore, ImportReport, import_legacy_directory_once, seed_default_environment,
+ seed_environments, seeded_catalog_layer,
};
diff --git a/lib/crates/fabro-environment/src/model.rs b/lib/crates/fabro-environment/src/model.rs
index 5f468938a..9ab67cb51 100644
--- a/lib/crates/fabro-environment/src/model.rs
+++ b/lib/crates/fabro-environment/src/model.rs
@@ -28,16 +28,27 @@ pub struct Environment {
}
impl Environment {
- pub(crate) fn from_persisted_path(
+ pub(crate) async fn from_legacy_path(
id: EnvironmentId,
bytes: &[u8],
path: &Path,
) -> Result {
- let revision = EnvironmentRevision::from_bytes(bytes);
let mut persisted = parse_persisted(bytes, path)?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
- inline_layer_dockerfile_paths(&mut persisted, base_dir)?;
+ inline_layer_dockerfile_paths(&mut persisted, base_dir).await?;
let settings = resolve_environment(&persisted)?;
+ Self::from_settings(id, &settings)
+ }
+
+ pub(crate) fn from_settings(
+ id: EnvironmentId,
+ settings: &EnvironmentSettings,
+ ) -> Result {
+ reject_dockerfile_paths(settings)?;
+ let persisted = environment_settings_to_layer(settings);
+ let settings = resolve_environment(&persisted)?;
+ let bytes = canonical_bytes(&persisted).into_bytes();
+ let revision = EnvironmentRevision::from_bytes(&bytes);
Ok(Self {
id,
revision,
@@ -45,24 +56,17 @@ impl Environment {
})
}
- pub(crate) async fn from_settings(
+ pub(crate) fn from_row(
id: EnvironmentId,
- settings: EnvironmentSettings,
- dockerfile_base_dir: &Path,
- ) -> Result<(Self, Vec), EnvironmentStoreError> {
- let settings = inline_dense_dockerfile(settings, dockerfile_base_dir).await?;
- let persisted = environment_settings_to_layer(&settings);
- let settings = resolve_environment(&persisted)?;
- let bytes = canonical_bytes(&persisted).into_bytes();
- let revision = EnvironmentRevision::from_bytes(&bytes);
- Ok((
- Self {
- id,
- revision,
- settings,
- },
- bytes,
- ))
+ revision: EnvironmentRevision,
+ layer: &EnvironmentLayer,
+ ) -> Result {
+ let settings = resolve_environment(layer)?;
+ Ok(Self {
+ id,
+ revision,
+ settings,
+ })
}
/// Builds an in-memory environment from settings without touching the
@@ -137,11 +141,7 @@ fn resolve_environment(
})
}
-#[expect(
- clippy::disallowed_methods,
- reason = "Dockerfile inlining runs during synchronous startup load before request handling."
-)]
-fn inline_layer_dockerfile_paths(
+async fn inline_layer_dockerfile_paths(
layer: &mut EnvironmentLayer,
base_dir: &Path,
) -> Result<(), EnvironmentValidationError> {
@@ -152,7 +152,7 @@ fn inline_layer_dockerfile_paths(
return Ok(());
};
let path = base_dir.join(path);
- let content = std::fs::read_to_string(&path).map_err(|source| {
+ let content = fs::read_to_string(&path).await.map_err(|source| {
EnvironmentValidationError::DockerfileRead {
path: path.clone(),
source,
@@ -162,22 +162,16 @@ fn inline_layer_dockerfile_paths(
Ok(())
}
-async fn inline_dense_dockerfile(
- mut settings: EnvironmentSettings,
- base_dir: &Path,
-) -> Result {
- let Some(DockerfileSource::Path { path }) = settings.image.dockerfile.as_ref() else {
- return Ok(settings);
- };
- let path = base_dir.join(path);
- let content = fs::read_to_string(&path).await.map_err(|source| {
- EnvironmentValidationError::DockerfileRead {
- path: path.clone(),
- source,
- }
- })?;
- settings.image.dockerfile = Some(DockerfileSource::Inline(content));
- Ok(settings)
+fn reject_dockerfile_paths(
+ settings: &EnvironmentSettings,
+) -> Result<(), EnvironmentValidationError> {
+ if matches!(
+ settings.image.dockerfile,
+ Some(DockerfileSource::Path { .. })
+ ) {
+ return Err(EnvironmentValidationError::DockerfilePathUnsupported);
+ }
+ Ok(())
}
fn environment_settings_to_layer(settings: &EnvironmentSettings) -> EnvironmentLayer {
diff --git a/lib/crates/fabro-environment/src/store.rs b/lib/crates/fabro-environment/src/store.rs
index 4e1ce9afe..8d465d459 100644
--- a/lib/crates/fabro-environment/src/store.rs
+++ b/lib/crates/fabro-environment/src/store.rs
@@ -1,29 +1,38 @@
-use std::collections::HashMap;
-use std::io::ErrorKind;
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::ffi::OsString;
use std::path::{Path, PathBuf};
+use std::str::FromStr;
use std::sync::Arc;
-use std::time::{SystemTime, UNIX_EPOCH};
-use fabro_config::{EnvironmentLayer, MergeMap};
-use fabro_types::settings::run::{EnvironmentProvider, EnvironmentSettings};
+use chrono::{DateTime, Utc};
+use fabro_config::{
+ EnvironmentDockerfileLayer, EnvironmentImageLayer, EnvironmentLayer, EnvironmentLifecycleLayer,
+ EnvironmentNetworkLayer, EnvironmentResourcesLayer, MergeMap, StickyMap,
+};
+use fabro_db::DbPool;
+use fabro_types::settings::run::{DockerfileSource, EnvironmentProvider, EnvironmentSettings};
+use fabro_types::settings::{Duration, InterpString, Size};
+use serde::de::DeserializeOwned;
+use sqlx::Row as _;
+use sqlx::sqlite::SqliteRow;
use tokio::fs;
-use tokio::io::AsyncWriteExt as _;
use tokio::sync::Mutex;
+use tracing::info;
use crate::{
Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError,
+ EnvironmentValidationError,
};
-/// Built-in default environment written to disk by the installer (see
-/// [`seed_default_environment`]). The server itself never seeds: a Fabro
-/// instance that has not been installed has no managed environments, and a run
-/// that selects an absent environment fails explicitly. `local` is
-/// intentionally absent: it is a reserved, in-memory environment (see
-/// [`RESERVED_LOCAL_ID`]).
+/// Built-in default environment seeded by install/test setup. The server itself
+/// never seeds during normal startup: an uninstalled instance has no persisted
+/// managed environments, and a run that selects an absent environment fails
+/// explicitly. `local` is intentionally absent from SQLite because it is a
+/// reserved, in-memory environment.
const DEFAULT_ENVIRONMENT_ID: &str = "default";
/// `local` is a reserved environment: it is synthesized in memory only when the
-/// local sandbox provider is enabled, is never persisted to disk, and cannot be
+/// local sandbox provider is enabled, is never persisted, and cannot be
/// created, replaced, or deleted through the store.
const RESERVED_LOCAL_ID: &str = "local";
@@ -76,10 +85,18 @@ stop_on_terminal = true
#[derive(Debug)]
pub struct EnvironmentStore {
- dir: PathBuf,
- request_base_dir: PathBuf,
- mutations: Mutex<()>,
- state: std::sync::RwLock,
+ pool: DbPool,
+ mutations: Mutex<()>,
+ state: std::sync::RwLock,
+}
+
+#[derive(Debug, Clone)]
+pub struct ImportReport {
+ pub source_path: PathBuf,
+ pub backup_path: PathBuf,
+ pub imported_rows: i64,
+ pub skipped_rows: i64,
+ pub environment_ids: Vec,
}
#[derive(Debug, Clone)]
@@ -97,7 +114,18 @@ impl CatalogState {
}
}
- fn refresh_catalog(&mut self) {
+ fn insert(&mut self, environment: Environment) {
+ self.environments
+ .insert(environment.id.clone(), environment);
+ self.rebuild_catalog();
+ }
+
+ fn remove(&mut self, id: &EnvironmentId) {
+ self.environments.remove(id);
+ self.rebuild_catalog();
+ }
+
+ fn rebuild_catalog(&mut self) {
self.catalog = Arc::new(build_catalog_layer(&self.environments));
}
}
@@ -125,29 +153,12 @@ fn build_catalog_layer(
}
impl EnvironmentStore {
- /// Synchronously load all persisted environments. The synchronous file
- /// access runs during server startup before request handling begins.
- ///
- /// The server never seeds the default environment; seeding is an
- /// install-time action (see [`seed_default_environment`]). An uninstalled
- /// instance therefore
- /// has no managed environments on disk, and the reserved `local`
- /// environment is the only entry present (when the local provider is
- /// enabled).
- pub fn load(
- dir: impl Into,
- local_enabled: bool,
- ) -> Result {
- let dir = dir.into();
- let mut environments = load_environments(&dir)?;
- if local_enabled {
- let local = synthetic_local_environment()?;
- environments.insert(local.id.clone(), local);
- }
- let request_base_dir = dir.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
+ /// Load all persisted environments from SQLite and build the synchronous
+ /// in-memory catalog cache used by request paths.
+ pub async fn load(pool: DbPool, local_enabled: bool) -> Result {
+ let environments = load_environments(&pool, local_enabled).await?;
Ok(Self {
- dir,
- request_base_dir,
+ pool,
mutations: Mutex::new(()),
state: std::sync::RwLock::new(CatalogState::new(environments)),
})
@@ -180,21 +191,15 @@ impl EnvironmentStore {
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id });
}
- let (environment, bytes) =
- Environment::from_settings(id.clone(), settings, &self.request_base_dir).await?;
+ let environment = Environment::from_settings(id.clone(), &settings)?;
+
let _mutation = self.mutations.lock().await;
- if self.read_state().environments.contains_key(&id) {
+ let mut transaction = self.pool.begin().await?;
+ if !insert_environment_ignoring_conflict(&mut transaction, &environment).await? {
return Err(EnvironmentStoreError::AlreadyExists { id });
}
-
- let path = environment_path(&self.dir, &id);
- write_new(&self.dir, &path, &bytes)
- .await
- .map_err(|err| create_error_for(id.clone(), err))?;
-
- let mut state = self.write_state();
- state.environments.insert(id, environment.clone());
- state.refresh_catalog();
+ transaction.commit().await?;
+ self.write_state().insert(environment.clone());
Ok(environment)
}
@@ -207,15 +212,13 @@ impl EnvironmentStore {
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id: id.clone() });
}
- let (environment, bytes) =
- Environment::from_settings(id.clone(), settings, &self.request_base_dir).await?;
- let _mutation = self.mutations.lock().await;
- check_revision(&self.read_state().environments, id, expected)?;
+ let environment = Environment::from_settings(id.clone(), &settings)?;
- write_atomic(&self.dir, &environment_path(&self.dir, id), &bytes).await?;
- let mut state = self.write_state();
- state.environments.insert(id.clone(), environment.clone());
- state.refresh_catalog();
+ let _mutation = self.mutations.lock().await;
+ let mut transaction = self.pool.begin().await?;
+ update_environment(&mut transaction, &environment, expected).await?;
+ transaction.commit().await?;
+ self.write_state().insert(environment.clone());
Ok(environment)
}
@@ -232,15 +235,17 @@ impl EnvironmentStore {
}
let _mutation = self.mutations.lock().await;
- check_revision(&self.read_state().environments, id, expected)?;
-
- let path = environment_path(&self.dir, id);
- fs::remove_file(&path)
- .await
- .map_err(|err| EnvironmentStoreError::io(path, err))?;
- let mut state = self.write_state();
- state.environments.remove(id);
- state.refresh_catalog();
+ let mut transaction = self.pool.begin().await?;
+ let result = sqlx::query("DELETE FROM environments WHERE id = ? AND revision = ?")
+ .bind(id.as_str())
+ .bind(expected.as_str())
+ .execute(&mut *transaction)
+ .await?;
+ if result.rows_affected() == 0 {
+ return Err(revision_mismatch_error(&mut transaction, id, expected).await?);
+ }
+ transaction.commit().await?;
+ self.write_state().remove(id);
Ok(())
}
@@ -249,112 +254,522 @@ impl EnvironmentStore {
}
}
-fn check_revision(
- environments: &HashMap,
+async fn load_environments(
+ pool: &DbPool,
+ local_enabled: bool,
+) -> Result, EnvironmentStoreError> {
+ let rows = sqlx::query(
+ r"
+ SELECT
+ id,
+ revision,
+ provider,
+ cwd,
+ image_docker,
+ image_dockerfile_inline,
+ resources_cpu,
+ resources_memory,
+ resources_disk,
+ network_mode,
+ network_allow_json,
+ lifecycle_preserve,
+ lifecycle_stop_on_terminal,
+ lifecycle_auto_stop,
+ labels_json,
+ env_json
+ FROM environments
+ ORDER BY id
+ ",
+ )
+ .fetch_all(pool)
+ .await?;
+
+ let mut environments = HashMap::new();
+ for row in rows {
+ let environment = environment_from_row(&row)?;
+ environments.insert(environment.id.clone(), environment);
+ }
+ if local_enabled {
+ let local = synthetic_local_environment()?;
+ environments.insert(local.id.clone(), local);
+ }
+ Ok(environments)
+}
+
+fn environment_from_row(row: &SqliteRow) -> Result {
+ let id_text = row.get::("id");
+ let id = EnvironmentId::new(id_text)?;
+ let revision_text = row.get::("revision");
+ let revision = EnvironmentRevision::from_str(&revision_text).map_err(|source| {
+ EnvironmentStoreError::InvalidRevision {
+ id: id.clone(),
+ source,
+ }
+ })?;
+ let network_allow_json = row.get::("network_allow_json");
+ let labels_json = row.get::("labels_json");
+ let env_json = row.get::("env_json");
+ let layer = EnvironmentLayer {
+ provider: Some(row.get("provider")),
+ cwd: row.get("cwd"),
+ image: image_layer_from_row(row),
+ resources: resources_layer_from_row(row)?,
+ network: Some(EnvironmentNetworkLayer {
+ mode: Some(row.get("network_mode")),
+ allow: decode_json("network_allow_json", &network_allow_json)?,
+ }),
+ lifecycle: Some(EnvironmentLifecycleLayer {
+ preserve: Some(row.get("lifecycle_preserve")),
+ stop_on_terminal: Some(row.get("lifecycle_stop_on_terminal")),
+ auto_stop: parse_duration(
+ "lifecycle_auto_stop",
+ row.get("lifecycle_auto_stop"),
+ )?,
+ }),
+ labels: StickyMap::from(decode_json::>(
+ "labels_json",
+ &labels_json,
+ )?),
+ env: StickyMap::from(decode_env_json(&env_json)?),
+ };
+
+ Environment::from_row(id, revision, &layer)
+}
+
+fn image_layer_from_row(row: &SqliteRow) -> Option {
+ let docker: Option = row.get("image_docker");
+ let dockerfile_inline: Option = row.get("image_dockerfile_inline");
+ if docker.is_none() && dockerfile_inline.is_none() {
+ return None;
+ }
+ Some(EnvironmentImageLayer {
+ docker,
+ dockerfile: dockerfile_inline.map(EnvironmentDockerfileLayer::Inline),
+ })
+}
+
+fn resources_layer_from_row(
+ row: &SqliteRow,
+) -> Result