mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-29 01:42:21 +00:00
feat: make local a reserved, in-memory environment
`local` no longer makes sense as a configurable environment — the local sandbox provider ignores image/resources/network/lifecycle/volumes. Stop seeding it as an editable TOML file and stop offering it as a creatable provider in the UI form. Instead the server synthesizes a reserved, in-memory `local` environment only when `server.sandbox.providers.local.enabled`. It is never persisted and cannot be created, replaced, or deleted (409). Provider-enablement is already enforced at run create/preflight/start, so the synthetic `local` just needs to exist in the store catalog when enabled. - fabro-environment: drop `local` from disk seeds; thread `local_enabled` into load_or_seed and inject a synthetic `local`; ignore any stale `local.toml`; reject create/replace/delete of `local` with a new Reserved error. seeded_catalog_layer keeps `local` for CLI validation. - fabro-server: pass local.enabled into the store; map Reserved -> 409. - web: form provider dropdown excludes local; the `local` row is read-only (no Edit/Delete) with a reserved tag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4dbbb4c27
commit
a163c16cda
9 changed files with 283 additions and 38 deletions
|
|
@ -250,11 +250,13 @@ export function EnvironmentFormFields({
|
|||
onChange={(e) => patch({ provider: parseProvider(e.target.value) })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{Object.values(EnvironmentProvider).map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider}
|
||||
</option>
|
||||
))}
|
||||
{Object.values(EnvironmentProvider)
|
||||
.filter((provider) => provider !== EnvironmentProvider.LOCAL)
|
||||
.map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</span>
|
||||
<Badge>{environment.provider}</Badge>
|
||||
{environment.id === RESERVED_ID ? (
|
||||
<StatusTag>reserved</StatusTag>
|
||||
) : environment.id === PROTECTED_ID ? (
|
||||
<StatusTag>protected</StatusTag>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs/5 text-fg-3">
|
||||
{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 (
|
||||
<span className="rounded-sm bg-overlay px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Menu as="div" className="relative inline-block">
|
||||
|
|
@ -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"
|
||||
>
|
||||
<MenuItem>
|
||||
<Link
|
||||
to={`/settings/environments/${encodeURIComponent(environment.id)}/edit`}
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
{reserved ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="The local environment is reserved and cannot be edited"
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={`/settings/environments/${encodeURIComponent(environment.id)}/edit`}
|
||||
className={MENU_ITEM_CLASS}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<hr className="my-1 h-px border-0 bg-line" />
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={disabled || protectedFromDelete}
|
||||
title={protectedFromDelete ? "The default environment is protected" : undefined}
|
||||
disabled={disabled || protectedFromDelete || reserved}
|
||||
title={
|
||||
reserved
|
||||
? "The local environment is reserved"
|
||||
: protectedFromDelete
|
||||
? "The default environment is protected"
|
||||
: undefined
|
||||
}
|
||||
className={MENU_ITEM_DANGER_CLASS}
|
||||
>
|
||||
{protectedFromDelete ? "Protected" : "Delete"}
|
||||
{reserved ? "Reserved" : protectedFromDelete ? "Protected" : "Delete"}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Self, EnvironmentStoreError> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<EnvironmentLayer> {
|
||||
let mut catalog: HashMap<String, EnvironmentLayer> = HashMap::new();
|
||||
for (id, body) in SEEDS {
|
||||
|
|
@ -31,6 +39,9 @@ pub fn seeded_catalog_layer() -> MergeMap<EnvironmentLayer> {
|
|||
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<Environment, EnvironmentStoreError> {
|
||||
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<EnvironmentId, Environment>,
|
||||
) -> MergeMap<EnvironmentLayer> {
|
||||
|
|
@ -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<PathBuf>) -> Result<Self, EnvironmentStoreError> {
|
||||
pub fn load_or_seed(
|
||||
dir: impl Into<PathBuf>,
|
||||
local_enabled: bool,
|
||||
) -> Result<Self, EnvironmentStoreError> {
|
||||
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<Environment, EnvironmentStoreError> {
|
||||
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<Environment, EnvironmentStoreError> {
|
||||
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<_>>(),
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -2319,8 +2319,15 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
|
|||
.context("load automations")?,
|
||||
);
|
||||
let environment_dir = environment_dir_for_active_config(&active_config_path);
|
||||
let local_provider_enabled = resolved_settings
|
||||
.server_settings
|
||||
.server
|
||||
.sandbox
|
||||
.providers
|
||||
.local
|
||||
.enabled;
|
||||
let environment_store = Arc::new(
|
||||
EnvironmentStore::load_or_seed(environment_dir)
|
||||
EnvironmentStore::load_or_seed(environment_dir, local_provider_enabled)
|
||||
.map_err(anyhow::Error::new)
|
||||
.context("load environments")?,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -244,6 +244,10 @@ impl From<EnvironmentStoreError> 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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue