diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 5d5a9b5f2..514d0b987 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -250,11 +250,13 @@ export function EnvironmentFormFields({ onChange={(e) => patch({ provider: parseProvider(e.target.value) })} className={INPUT_CLASS} > - {Object.values(EnvironmentProvider).map((provider) => ( - - ))} + {Object.values(EnvironmentProvider) + .filter((provider) => provider !== EnvironmentProvider.LOCAL) + .map((provider) => ( + + ))} diff --git a/apps/fabro-web/app/routes/settings-environments.tsx b/apps/fabro-web/app/routes/settings-environments.tsx index 500a4e3c0..cde43bfac 100644 --- a/apps/fabro-web/app/routes/settings-environments.tsx +++ b/apps/fabro-web/app/routes/settings-environments.tsx @@ -23,6 +23,10 @@ import { useToast } from "../components/toast"; // in the UI instead of letting the delete fail with a 409. const PROTECTED_ID = "default"; +// `local` is a reserved, in-memory environment (present only when the local +// sandbox provider is enabled). It cannot be edited or deleted. +const RESERVED_ID = "local"; + const MENU_ITEM_CLASS = "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"; @@ -153,6 +157,11 @@ function EnvironmentRow({ {environment.id} {environment.provider} + {environment.id === RESERVED_ID ? ( + reserved + ) : environment.id === PROTECTED_ID ? ( + protected + ) : null}
{resourcesSummary(environment)} · network {environment.network.mode} @@ -184,6 +193,14 @@ function resourcesSummary(environment: Environment): string { return parts.length > 0 ? parts.join(" · ") : "Default resources"; } +function StatusTag({ children }: { children: string }) { + return ( + + {children} + + ); +} + function RowMenu({ environment, disabled, @@ -193,6 +210,7 @@ function RowMenu({ disabled: boolean; onDelete: () => void; }) { + const reserved = environment.id === RESERVED_ID; const protectedFromDelete = environment.id === PROTECTED_ID; return ( @@ -211,23 +229,40 @@ function RowMenu({ className="z-30 w-36 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in" > - - Edit - + {reserved ? ( + + ) : ( + + Edit + + )}
diff --git a/lib/crates/fabro-environment/src/error.rs b/lib/crates/fabro-environment/src/error.rs index aab0f2e3d..81b624f68 100644 --- a/lib/crates/fabro-environment/src/error.rs +++ b/lib/crates/fabro-environment/src/error.rs @@ -33,6 +33,8 @@ pub enum EnvironmentStoreError { }, #[error("environment is protected and cannot be deleted: {id}")] Protected { id: EnvironmentId }, + #[error("environment is reserved and cannot be modified: {id}")] + Reserved { id: EnvironmentId }, #[error("environment validation failed: {source}")] Validation { #[from] @@ -94,6 +96,7 @@ impl EnvironmentStoreError { Self::AlreadyExists { .. } => "already_exists", Self::StaleRevision { .. } => "stale_revision", Self::Protected { .. } => "protected", + Self::Reserved { .. } => "reserved", Self::Validation { .. } => "validation", Self::InvalidFilename { .. } => "invalid_filename", Self::Parse { .. } | Self::InvalidUtf8 { .. } => "parse", diff --git a/lib/crates/fabro-environment/src/model.rs b/lib/crates/fabro-environment/src/model.rs index 2943ac538..47fc71e88 100644 --- a/lib/crates/fabro-environment/src/model.rs +++ b/lib/crates/fabro-environment/src/model.rs @@ -65,6 +65,25 @@ impl Environment { )) } + /// Builds an in-memory environment from settings without touching the + /// filesystem. Unlike [`from_settings`], this never inlines Dockerfile + /// paths, so it stays synchronous — suitable for reserved environments + /// (e.g. `local`) that carry no Dockerfile and are never persisted. + pub(crate) fn synthetic( + id: EnvironmentId, + settings: &EnvironmentSettings, + ) -> Result { + 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, + }) + } + pub(crate) fn to_layer(&self) -> EnvironmentLayer { environment_settings_to_layer(&self.settings) } diff --git a/lib/crates/fabro-environment/src/store.rs b/lib/crates/fabro-environment/src/store.rs index b6265d7ed..a9fca768a 100644 --- a/lib/crates/fabro-environment/src/store.rs +++ b/lib/crates/fabro-environment/src/store.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use fabro_config::{EnvironmentLayer, MergeMap}; -use fabro_types::settings::run::EnvironmentSettings; +use fabro_types::settings::run::{EnvironmentProvider, EnvironmentSettings}; use tokio::fs; use tokio::io::AsyncWriteExt as _; use tokio::sync::Mutex; @@ -14,16 +14,24 @@ use crate::{ Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError, }; +/// Environments written to disk on first startup. `local` is intentionally +/// absent: it is a reserved, in-memory environment (see [`RESERVED_LOCAL_ID`]). const SEEDS: &[(&str, &str)] = &[ ("default", DEFAULT_ENVIRONMENT_TOML), - ("local", LOCAL_ENVIRONMENT_TOML), ("docker", DOCKER_ENVIRONMENT_TOML), ("daytona", DAYTONA_ENVIRONMENT_TOML), ]; +/// `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 +/// created, replaced, or deleted through the store. +const RESERVED_LOCAL_ID: &str = "local"; + /// Returns the built-in seeded environment catalog as a `MergeMap` of /// `EnvironmentLayer`s. Useful for client-side manifest validation where no -/// live `EnvironmentStore` is available. +/// live `EnvironmentStore` is available. Includes the reserved `local` entry so +/// manifests selecting `id = "local"` validate; server-side provider-enablement +/// policy decides whether such a run may actually execute. pub fn seeded_catalog_layer() -> MergeMap { let mut catalog: HashMap = HashMap::new(); for (id, body) in SEEDS { @@ -31,6 +39,9 @@ pub fn seeded_catalog_layer() -> MergeMap { toml::from_str(body).expect("built-in environment seed should parse"); catalog.insert((*id).to_string(), layer); } + let local: EnvironmentLayer = toml::from_str(LOCAL_ENVIRONMENT_TOML) + .expect("built-in local environment seed should parse"); + catalog.insert(RESERVED_LOCAL_ID.to_string(), local); MergeMap::from(catalog) } @@ -96,6 +107,18 @@ impl CatalogState { } } +/// Builds the reserved, in-memory `local` environment. It carries only +/// `provider = "local"`; image/resources/network/etc. are irrelevant to the +/// local sandbox and stay at their defaults. +fn synthetic_local_environment() -> Result { + let id = EnvironmentId::new(RESERVED_LOCAL_ID).expect("reserved local id is valid"); + let settings = EnvironmentSettings { + provider: EnvironmentProvider::Local, + ..EnvironmentSettings::default() + }; + Environment::synthetic(id, &settings) +} + fn build_catalog_layer( environments: &HashMap, ) -> MergeMap { @@ -110,10 +133,17 @@ impl EnvironmentStore { /// Synchronously seed missing built-in environment files and load all /// persisted environments. The synchronous file access runs during server /// startup before request handling begins. - pub fn load_or_seed(dir: impl Into) -> Result { + pub fn load_or_seed( + dir: impl Into, + local_enabled: bool, + ) -> Result { let dir = dir.into(); seed_missing_environments(&dir)?; - let environments = load_environments(&dir)?; + 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(); Ok(Self { dir, @@ -147,6 +177,9 @@ impl EnvironmentStore { draft: EnvironmentDraft, ) -> Result { let EnvironmentDraft { id, settings } = draft; + 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 _mutation = self.mutations.lock().await; @@ -171,6 +204,9 @@ impl EnvironmentStore { expected: &EnvironmentRevision, settings: EnvironmentSettings, ) -> Result { + 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; @@ -191,6 +227,9 @@ impl EnvironmentStore { if id.as_str() == "default" { return Err(EnvironmentStoreError::Protected { id: id.clone() }); } + if id.as_str() == RESERVED_LOCAL_ID { + return Err(EnvironmentStoreError::Reserved { id: id.clone() }); + } let _mutation = self.mutations.lock().await; check_revision(&self.read_state().environments, id, expected)?; @@ -279,6 +318,11 @@ fn load_environments( if !file_type.is_file() || !is_toml_file(&path) { continue; } + // `local` is reserved and synthesized in memory; never load a stale + // `local.toml` left behind by an earlier build that seeded it. + if id_from_path(&path).is_ok_and(|id| id.as_str() == RESERVED_LOCAL_ID) { + continue; + } let environment = load_environment_file(&path)?; environments.insert(environment.id.clone(), environment); } @@ -448,9 +492,11 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let environment_dir = dir.path().join("environments"); - let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap(); + let store = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap(); let environments = store.list(); + // `local` is present in memory (local provider enabled) but the other + // three are the on-disk seeds. assert_eq!( environments .iter() @@ -458,9 +504,73 @@ mod tests { .collect::>(), vec!["daytona", "default", "docker", "local"] ); - for id in ["default", "local", "docker", "daytona"] { + for id in ["default", "docker", "daytona"] { assert!(environment_dir.join(format!("{id}.toml")).exists()); } + // `local` is reserved and in-memory; it is never written to disk. + assert!(!environment_dir.join("local.toml").exists()); + } + + #[tokio::test] + async fn local_present_only_when_enabled() { + let dir = tempfile::tempdir().unwrap(); + let environment_dir = dir.path().join("environments"); + + let enabled = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap(); + assert!(enabled.get(&EnvironmentId::new("local").unwrap()).is_some()); + + let disabled = EnvironmentStore::load_or_seed(&environment_dir, false).unwrap(); + assert!( + disabled + .get(&EnvironmentId::new("local").unwrap()) + .is_none() + ); + } + + #[tokio::test] + async fn on_disk_local_is_ignored_in_favor_of_synthetic() { + let dir = tempfile::tempdir().unwrap(); + let environment_dir = dir.path().join("environments"); + fs::create_dir_all(&environment_dir).await.unwrap(); + // A stale `local.toml` left by an earlier build that seeded it. + fs::write( + environment_dir.join("local.toml"), + "provider = \"local\"\n[resources]\ncpu = 99\n", + ) + .await + .unwrap(); + + let store = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap(); + let local = store.get(&EnvironmentId::new("local").unwrap()).unwrap(); + + // The synthetic local carries no resources; the stale file was ignored. + assert_eq!(local.settings.resources.cpu, None); + } + + #[tokio::test] + async fn local_mutations_are_reserved() { + let dir = tempfile::tempdir().unwrap(); + let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap(); + let local = EnvironmentId::new("local").unwrap(); + let revision = store.get(&local).unwrap().revision; + + let create_err = store + .create(draft("local", EnvironmentProvider::Local)) + .await + .unwrap_err(); + assert!(matches!(create_err, EnvironmentStoreError::Reserved { .. })); + + let replace_err = store + .replace(&local, &revision, settings(EnvironmentProvider::Local)) + .await + .unwrap_err(); + assert!(matches!( + replace_err, + EnvironmentStoreError::Reserved { .. } + )); + + let delete_err = store.delete(&local, &revision).await.unwrap_err(); + assert!(matches!(delete_err, EnvironmentStoreError::Reserved { .. })); } #[tokio::test] @@ -475,7 +585,7 @@ mod tests { .await .unwrap(); - let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap(); + let store = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap(); assert_eq!( store @@ -494,7 +604,7 @@ mod tests { std::fs::create_dir_all(&environment_dir).unwrap(); std::fs::write(environment_dir.join("Bad.toml"), r#"provider = "local""#).unwrap(); - let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err(); + let err = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap_err(); assert!(matches!(err, EnvironmentStoreError::InvalidFilename { .. })); } @@ -506,7 +616,7 @@ mod tests { std::fs::create_dir_all(&environment_dir).unwrap(); std::fs::write(environment_dir.join("bad.toml"), r#"provider = "bogus""#).unwrap(); - let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err(); + let err = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap_err(); assert!(matches!(err, EnvironmentStoreError::Validation { .. })); assert!(err.to_string().contains("unknown environment provider")); @@ -528,7 +638,7 @@ mode = "cidr_allow_list" ) .unwrap(); - let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err(); + let err = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap_err(); assert!(matches!(err, EnvironmentStoreError::Validation { .. })); assert!( @@ -553,7 +663,7 @@ path = "Dockerfile" ) .unwrap(); - let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err(); + let err = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap_err(); assert!(matches!(err, EnvironmentStoreError::Validation { .. })); assert!(err.to_string().contains("Dockerfile")); @@ -562,10 +672,10 @@ path = "Dockerfile" #[tokio::test] async fn create_conflict_is_rejected() { let dir = tempfile::tempdir().unwrap(); - let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap(); + let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap(); let err = store - .create(draft("local", EnvironmentProvider::Local)) + .create(draft("docker", EnvironmentProvider::Docker)) .await .unwrap_err(); @@ -575,7 +685,7 @@ path = "Dockerfile" #[tokio::test] async fn create_invalid_settings_is_rejected() { let dir = tempfile::tempdir().unwrap(); - let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap(); + let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap(); let mut settings = settings(EnvironmentProvider::Local); settings.network.mode = EnvironmentNetworkMode::Block; @@ -597,8 +707,8 @@ path = "Dockerfile" #[tokio::test] async fn replace_stale_revision_is_rejected() { let dir = tempfile::tempdir().unwrap(); - let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap(); - let current = store.get(&EnvironmentId::new("local").unwrap()).unwrap(); + let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap(); + let current = store.get(&EnvironmentId::new("docker").unwrap()).unwrap(); let stale = EnvironmentRevision::from_bytes(b"stale"); let err = store @@ -612,7 +722,7 @@ path = "Dockerfile" #[tokio::test] async fn default_delete_is_rejected() { let dir = tempfile::tempdir().unwrap(); - let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap(); + let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap(); let default = store.get(&EnvironmentId::new("default").unwrap()).unwrap(); let err = store @@ -627,7 +737,7 @@ path = "Dockerfile" async fn delete_success_removes_file_and_memory_entry() { let dir = tempfile::tempdir().unwrap(); let environment_dir = dir.path().join("environments"); - let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap(); + let store = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap(); let created = store .create(draft("tmp", EnvironmentProvider::Local)) .await @@ -642,7 +752,7 @@ path = "Dockerfile" #[tokio::test] async fn canonical_revision_changes_when_persisted_bytes_change() { let dir = tempfile::tempdir().unwrap(); - let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap(); + let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap(); let created = store .create(draft("rev", EnvironmentProvider::Local)) .await @@ -667,7 +777,7 @@ path = "Dockerfile" fs::write(dir.path().join("Dockerfile"), "FROM alpine\n") .await .unwrap(); - let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap(); + let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap(); let mut settings = settings(EnvironmentProvider::Docker); settings.image.dockerfile = Some(DockerfileSource::Path { path: "Dockerfile".to_string(), diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 7bff4968f..acfa3d725 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2319,8 +2319,15 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result for ApiError { StatusCode::CONFLICT, format!("environment is protected and cannot be deleted: {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()) } diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 86e3bfeb3..caef6fac2 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -1222,7 +1222,7 @@ id = "missing" #[test] fn system_sandbox_provider_uses_manifest_defaults() { let temp = tempfile::tempdir().unwrap(); - let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments")) + let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments"), true) .expect("environment store should seed"); let source = r#" _version = 1 @@ -1241,7 +1241,7 @@ id = "daytona" #[test] fn system_sandbox_provider_defaults_when_manifest_run_settings_do_not_resolve() { let temp = tempfile::tempdir().unwrap(); - let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments")) + let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments"), true) .expect("environment store should seed"); let source = r#" _version = 1 diff --git a/lib/crates/fabro-server/tests/it/api/environments.rs b/lib/crates/fabro-server/tests/it/api/environments.rs index fb0c9fbfa..5d76a9aa8 100644 --- a/lib/crates/fabro-server/tests/it/api/environments.rs +++ b/lib/crates/fabro-server/tests/it/api/environments.rs @@ -407,6 +407,71 @@ async fn duplicate_environment_create_returns_conflict() { .await; } +#[tokio::test] +async fn reserved_local_environment_cannot_be_created_or_modified() { + let (app, _temp_dir, _environment_dir) = environment_app(); + + // `local` is reserved: creating it is rejected with a conflict. + let created = app + .clone() + .oneshot(json_request( + Method::POST, + "/environments", + &environment_body("local", "local"), + )) + .await + .expect("reserved create should respond"); + response_status( + created, + StatusCode::CONFLICT, + "POST /api/v1/environments local", + ) + .await; + + // It is synthesized in memory (local provider enabled) and readable. + let local = app + .clone() + .oneshot(empty_request(Method::GET, "/environments/local")) + .await + .expect("get local should respond"); + let local = response_json(local, StatusCode::OK, "GET /api/v1/environments/local").await; + let revision = revision_from(&local); + + // Replace and delete are rejected even with a valid If-Match. + let replaced = app + .clone() + .oneshot(request_with_if_match( + Method::PUT, + "/environments/local", + &format!("\"{revision}\""), + Some(environment_settings("local")), + )) + .await + .expect("reserved replace should respond"); + response_status( + replaced, + StatusCode::CONFLICT, + "PUT /api/v1/environments/local", + ) + .await; + + let deleted = app + .oneshot(request_with_if_match( + Method::DELETE, + "/environments/local", + &format!("\"{revision}\""), + None, + )) + .await + .expect("reserved delete should respond"); + response_status( + deleted, + StatusCode::CONFLICT, + "DELETE /api/v1/environments/local", + ) + .await; +} + #[tokio::test] async fn invalid_environment_id_and_if_match_return_bad_request() { let (app, _temp_dir, _environment_dir) = environment_app();