fabro/lib/crates/fabro-environment/src/store.rs
fabro-sh-0530[bot] 4cff07373c
feat: add server-owned environment store (Task 1 & 2 foundation) (#446)
## Summary

Moves environment definitions out of project/workflow TOML config and
into server-owned files, introducing the `fabro-environment` crate and
enforcing source-aware validation so project/workflow/user configs can
no longer define environment catalogs.

### What changed

**New `fabro-environment` crate** — workspace crate wired into
`fabro-cli` and `fabro-server`. Exposes a `seeded_catalog_layer()` that
CLI commands inject at the call site to fill the environment catalog
that settings resolution requires.

**Config environments are now migration-only** — `defaults.toml` no
longer ships a built-in `[environments.*]` catalog. Instead:
- `SettingsSource` enum tags every parsed layer (ActiveSettings,
Project, Workflow, DirectRun, User).
- `validate_settings_source` rejects `[environments.<id>]` in any source
except `ActiveSettings` with a targeted message: `[environments.<id>] is
now server-managed; move this definition to the server environments
directory`.
- TOML-provided
`run.environment.{image,resources,network,lifecycle,labels,volumes,env}`
overrides are also rejected; only `run.environment.id` survives.

**New migration** (`2026052801_settings_environments_to_server_files`) —
chains after the existing legacy-sandbox migration. Extracts
`[environments.*]` entries from `settings.toml` into sibling
`environments/<id>.toml` files, writes a
`.settings-environments-migration.bak` backup, and fails without
modifying any file if a target already exists.

**Builder API additions** —
`RunSettingsBuilder::load_from_with_catalog`,
`load_default_with_catalog`, `from_toml_with_catalog` let callers inject
a server-side catalog; the bare `from_toml` path now errors if no
catalog is present and a named environment is selected.
`WorkflowSettingsBuilder` test helpers in `src/tests/mod.rs` centralise
catalog injection across all config tests.

**`.fabro/project.toml`** — removed the inline
`[environments.fabro-dev]` block (environment definition now lives
server-side).

### Key design decisions

- CLI offline commands (graph, preflight, validate) use
`seeded_catalog_layer()` as a local stand-in until a running server is
available — matches the pre-existing behaviour without regressing
offline workflows.
- `load_settings_path` no longer runs migrations for non-ActiveSettings
sources, preventing project/workflow files from accidentally triggering
file-system writes.
- The `MigrationReport` type is now the new migration's
`SettingsEnvironmentsMigrationReport` (exposes `contents: String`
instead of a parsed layer), keeping `load.rs` simpler and decoupled from
layer parsing.


### Fabro Details

<details>
<summary>Ran 9 stages in 143m 23s for $105.27</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 11s | – | 0 |
| preflight_lint | 2m 23s | – | 0 |
| implement | 28m 27s | – | 0 |
| simplify_opus | 37m 27s | $53.28 | 0 |
| simplify_gpt | 20m 50s | $12.14 | 0 |
| verify | 6m 3s | – | 0 |
| fixup | 45m 15s | $39.84 | 0 |
| **Total** | **143m 23s** | **$105.27** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-05-28 17:10:59 -04:00

671 lines
22 KiB
Rust

use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use fabro_config::{EnvironmentLayer, MergeMap};
use fabro_types::settings::run::EnvironmentSettings;
use tokio::fs;
use tokio::io::AsyncWriteExt as _;
use tokio::sync::Mutex;
use crate::{
Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError,
};
const SEEDS: &[(&str, &str)] = &[
("default", DEFAULT_ENVIRONMENT_TOML),
("local", LOCAL_ENVIRONMENT_TOML),
("docker", DOCKER_ENVIRONMENT_TOML),
("daytona", DAYTONA_ENVIRONMENT_TOML),
];
/// 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.
pub fn seeded_catalog_layer() -> MergeMap<EnvironmentLayer> {
let mut catalog: HashMap<String, EnvironmentLayer> = HashMap::new();
for (id, body) in SEEDS {
let layer: EnvironmentLayer =
toml::from_str(body).expect("built-in environment seed should parse");
catalog.insert((*id).to_string(), layer);
}
MergeMap::from(catalog)
}
const DEFAULT_ENVIRONMENT_TOML: &str = r#"provider = "docker"
[image]
docker = "buildpack-deps:noble"
[resources]
cpu = 2
memory = "4GB"
[lifecycle]
preserve = false
stop_on_terminal = true
"#;
const LOCAL_ENVIRONMENT_TOML: &str = r#"provider = "local"
"#;
const DOCKER_ENVIRONMENT_TOML: &str = r#"provider = "docker"
[image]
docker = "buildpack-deps:noble"
[resources]
cpu = 2
memory = "4GB"
[lifecycle]
preserve = false
stop_on_terminal = true
"#;
const DAYTONA_ENVIRONMENT_TOML: &str = r#"provider = "daytona"
"#;
#[derive(Debug)]
pub struct EnvironmentStore {
dir: PathBuf,
request_base_dir: PathBuf,
mutations: Mutex<()>,
state: std::sync::RwLock<CatalogState>,
}
#[derive(Debug, Clone)]
struct CatalogState {
environments: HashMap<EnvironmentId, Environment>,
catalog: Arc<MergeMap<EnvironmentLayer>>,
}
impl CatalogState {
fn new(environments: HashMap<EnvironmentId, Environment>) -> Self {
let catalog = Arc::new(build_catalog_layer(&environments));
Self {
environments,
catalog,
}
}
fn refresh_catalog(&mut self) {
self.catalog = Arc::new(build_catalog_layer(&self.environments));
}
}
fn build_catalog_layer(
environments: &HashMap<EnvironmentId, Environment>,
) -> MergeMap<EnvironmentLayer> {
let catalog: HashMap<String, EnvironmentLayer> = environments
.iter()
.map(|(id, environment)| (id.to_string(), environment.to_layer()))
.collect();
MergeMap::from(catalog)
}
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> {
let dir = dir.into();
seed_missing_environments(&dir)?;
let environments = load_environments(&dir)?;
let request_base_dir = dir.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
Ok(Self {
dir,
request_base_dir,
mutations: Mutex::new(()),
state: std::sync::RwLock::new(CatalogState::new(environments)),
})
}
fn read_state(&self) -> std::sync::RwLockReadGuard<'_, CatalogState> {
self.state.read().expect("environment store lock poisoned")
}
fn write_state(&self) -> std::sync::RwLockWriteGuard<'_, CatalogState> {
self.state.write().expect("environment store lock poisoned")
}
pub fn list(&self) -> Vec<Environment> {
let state = self.read_state();
let mut values = state.environments.values().cloned().collect::<Vec<_>>();
values.sort_by(|left, right| left.id.cmp(&right.id));
values
}
pub fn get(&self, id: &EnvironmentId) -> Option<Environment> {
self.read_state().environments.get(id).cloned()
}
pub async fn create(
&self,
draft: EnvironmentDraft,
) -> Result<Environment, EnvironmentStoreError> {
let EnvironmentDraft { id, settings } = draft;
let (environment, bytes) =
Environment::from_settings(id.clone(), settings, &self.request_base_dir).await?;
let _mutation = self.mutations.lock().await;
if self.read_state().environments.contains_key(&id) {
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();
Ok(environment)
}
pub async fn replace(
&self,
id: &EnvironmentId,
expected: &EnvironmentRevision,
settings: EnvironmentSettings,
) -> Result<Environment, EnvironmentStoreError> {
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)?;
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();
Ok(environment)
}
pub async fn delete(
&self,
id: &EnvironmentId,
expected: &EnvironmentRevision,
) -> Result<(), EnvironmentStoreError> {
if id.as_str() == "default" {
return Err(EnvironmentStoreError::Protected { id: id.clone() });
}
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();
Ok(())
}
pub fn catalog_layer(&self) -> Arc<MergeMap<EnvironmentLayer>> {
Arc::clone(&self.read_state().catalog)
}
}
fn check_revision(
environments: &HashMap<EnvironmentId, Environment>,
id: &EnvironmentId,
expected: &EnvironmentRevision,
) -> Result<(), EnvironmentStoreError> {
let current = environments
.get(id)
.ok_or_else(|| EnvironmentStoreError::NotFound { id: id.clone() })?;
if &current.revision != expected {
return Err(EnvironmentStoreError::StaleRevision {
id: id.clone(),
expected: expected.clone(),
actual: current.revision.clone(),
});
}
Ok(())
}
#[expect(
clippy::disallowed_methods,
clippy::disallowed_types,
reason = "Environment directory seeding runs synchronously during startup before request handling."
)]
fn seed_missing_environments(dir: &Path) -> Result<(), EnvironmentStoreError> {
std::fs::create_dir_all(dir).map_err(|err| EnvironmentStoreError::io(dir, err))?;
for (id, content) in SEEDS {
let path = dir.join(format!("{id}.toml"));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write as _;
file.write_all(content.as_bytes())
.map_err(|err| EnvironmentStoreError::io(&path, err))?;
file.sync_all()
.map_err(|err| EnvironmentStoreError::io(&path, err))?;
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
Err(err) => return Err(EnvironmentStoreError::io(path, err)),
}
}
Ok(())
}
#[expect(
clippy::disallowed_methods,
reason = "Environment directory scan runs once at startup; std::fs avoids requiring a Tokio runtime for callers."
)]
fn load_environments(
dir: &Path,
) -> Result<HashMap<EnvironmentId, Environment>, EnvironmentStoreError> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(HashMap::new()),
Err(err) => return Err(EnvironmentStoreError::io(dir, err)),
};
let mut environments = HashMap::new();
for entry in entries {
let entry = entry.map_err(|err| EnvironmentStoreError::io(dir, err))?;
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|err| EnvironmentStoreError::io(&path, err))?;
if !file_type.is_file() || !is_toml_file(&path) {
continue;
}
let environment = load_environment_file(&path)?;
environments.insert(environment.id.clone(), environment);
}
Ok(environments)
}
#[expect(
clippy::disallowed_methods,
reason = "Sync sibling of `load_environments`; only invoked from the synchronous startup load path."
)]
fn load_environment_file(path: &Path) -> Result<Environment, EnvironmentStoreError> {
let id = id_from_path(path)?;
let bytes = std::fs::read(path).map_err(|err| EnvironmentStoreError::io(path, err))?;
Environment::from_persisted_path(id, &bytes, path)
}
fn id_from_path(path: &Path) -> Result<EnvironmentId, EnvironmentStoreError> {
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| EnvironmentStoreError::InvalidFilename {
path: path.to_path_buf(),
reason: "filename is not valid UTF-8".to_string(),
})?;
EnvironmentId::new(stem).map_err(|source| EnvironmentStoreError::InvalidFilename {
path: path.to_path_buf(),
reason: source.to_string(),
})
}
fn is_toml_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension == "toml")
}
async fn write_atomic(dir: &Path, path: &Path, bytes: &[u8]) -> Result<(), EnvironmentStoreError> {
fs::create_dir_all(dir)
.await
.map_err(|err| EnvironmentStoreError::io(dir, err))?;
let temp_path = temp_path_for(path);
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)
.await
.map_err(|err| EnvironmentStoreError::io(&temp_path, err))?;
if let Err(err) = file.write_all(bytes).await {
cleanup_temp(&temp_path).await;
return Err(EnvironmentStoreError::io(&temp_path, err));
}
if let Err(err) = file.sync_all().await {
cleanup_temp(&temp_path).await;
return Err(EnvironmentStoreError::io(&temp_path, err));
}
drop(file);
if let Err(err) = fs::rename(&temp_path, path).await {
cleanup_temp(&temp_path).await;
return Err(EnvironmentStoreError::io(path, err));
}
Ok(())
}
async fn write_new(dir: &Path, path: &Path, bytes: &[u8]) -> Result<(), EnvironmentStoreError> {
fs::create_dir_all(dir)
.await
.map_err(|err| EnvironmentStoreError::io(dir, err))?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.await
.map_err(|err| EnvironmentStoreError::io(path, err))?;
file.write_all(bytes)
.await
.map_err(|err| EnvironmentStoreError::io(path, err))?;
file.sync_all()
.await
.map_err(|err| EnvironmentStoreError::io(path, err))?;
Ok(())
}
async fn cleanup_temp(path: &Path) {
let _ = fs::remove_file(path).await;
}
fn create_error_for(id: EnvironmentId, err: EnvironmentStoreError) -> EnvironmentStoreError {
match err {
EnvironmentStoreError::Io { source, .. } if source.kind() == ErrorKind::AlreadyExists => {
EnvironmentStoreError::AlreadyExists { id }
}
err => err,
}
}
fn temp_path_for(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("environment.toml");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), now))
}
fn environment_path(dir: &Path, id: &EnvironmentId) -> PathBuf {
dir.join(format!("{id}.toml"))
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "Unit tests for sync startup helpers use sync std::fs to set up fixtures."
)]
mod tests {
use std::collections::HashMap;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
EnvironmentSettings,
};
use tokio::fs;
use crate::{
EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStore,
EnvironmentStoreError,
};
fn settings(provider: EnvironmentProvider) -> EnvironmentSettings {
EnvironmentSettings {
provider,
image: EnvironmentImageSettings::default(),
resources: EnvironmentResourcesSettings::default(),
network: EnvironmentNetworkSettings::default(),
lifecycle: EnvironmentLifecycleSettings::default(),
labels: HashMap::new(),
volumes: Vec::new(),
env: HashMap::new(),
}
}
fn draft(id: &str, provider: EnvironmentProvider) -> EnvironmentDraft {
EnvironmentDraft {
id: EnvironmentId::new(id).unwrap(),
settings: settings(provider),
}
}
#[test]
fn seeded_catalog_layer_contains_built_ins() {
let catalog = super::seeded_catalog_layer();
let inner = catalog.into_inner();
for id in ["default", "local", "docker", "daytona"] {
assert!(inner.contains_key(id), "missing {id}");
}
}
#[tokio::test]
async fn absent_directory_loads_and_seeds_built_ins() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap();
let environments = store.list();
assert_eq!(
environments
.iter()
.map(|environment| environment.id.as_str())
.collect::<Vec<_>>(),
vec!["daytona", "default", "docker", "local"]
);
for id in ["default", "local", "docker", "daytona"] {
assert!(environment_dir.join(format!("{id}.toml")).exists());
}
}
#[tokio::test]
async fn listing_is_sorted() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
fs::create_dir_all(&environment_dir).await.unwrap();
fs::write(environment_dir.join("z.toml"), r#"provider = "local""#)
.await
.unwrap();
fs::write(environment_dir.join("a.toml"), r#"provider = "local""#)
.await
.unwrap();
let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap();
assert_eq!(
store
.list()
.iter()
.map(|environment| environment.id.as_str())
.collect::<Vec<_>>(),
vec!["a", "daytona", "default", "docker", "local", "z"]
);
}
#[test]
fn invalid_id_file_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
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();
assert!(matches!(err, EnvironmentStoreError::InvalidFilename { .. }));
}
#[test]
fn invalid_provider_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
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();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(err.to_string().contains("unknown environment provider"));
}
#[test]
fn invalid_network_mode_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
std::fs::create_dir_all(&environment_dir).unwrap();
std::fs::write(
environment_dir.join("bad.toml"),
r#"
provider = "docker"
[network]
mode = "cidr_allow_list"
"#,
)
.unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(
err.to_string()
.contains("docker environments cannot enforce")
);
}
#[test]
fn missing_dockerfile_path_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
std::fs::create_dir_all(&environment_dir).unwrap();
std::fs::write(
environment_dir.join("bad.toml"),
r#"
provider = "docker"
[image.dockerfile]
path = "Dockerfile"
"#,
)
.unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(err.to_string().contains("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 err = store
.create(draft("local", EnvironmentProvider::Local))
.await
.unwrap_err();
assert!(matches!(err, EnvironmentStoreError::AlreadyExists { .. }));
}
#[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 stale = EnvironmentRevision::from_bytes(b"stale");
let err = store
.replace(&current.id, &stale, settings(EnvironmentProvider::Docker))
.await
.unwrap_err();
assert!(matches!(err, EnvironmentStoreError::StaleRevision { .. }));
}
#[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 default = store.get(&EnvironmentId::new("default").unwrap()).unwrap();
let err = store
.delete(&default.id, &default.revision)
.await
.unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Protected { .. }));
}
#[tokio::test]
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 created = store
.create(draft("tmp", EnvironmentProvider::Local))
.await
.unwrap();
store.delete(&created.id, &created.revision).await.unwrap();
assert!(store.get(&created.id).is_none());
assert!(!environment_dir.join("tmp.toml").exists());
}
#[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 created = store
.create(draft("rev", EnvironmentProvider::Local))
.await
.unwrap();
let mut next = settings(EnvironmentProvider::Local);
next.env.insert(
"TOKEN".to_string(),
InterpString::parse("{{ env.TEST_TOKEN }}"),
);
let replaced = store
.replace(&created.id, &created.revision, next)
.await
.unwrap();
assert_ne!(created.revision, replaced.revision);
}
#[tokio::test]
async fn api_dockerfile_path_is_resolved_relative_to_settings_dir_and_persisted_inline() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("Dockerfile"), "FROM alpine\n")
.await
.unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let mut settings = settings(EnvironmentProvider::Docker);
settings.image.dockerfile = Some(DockerfileSource::Path {
path: "Dockerfile".to_string(),
});
let draft = EnvironmentDraft {
id: EnvironmentId::new("with-dockerfile").unwrap(),
settings,
};
let created = store.create(draft).await.unwrap();
let persisted =
fs::read_to_string(dir.path().join("environments").join("with-dockerfile.toml"))
.await
.unwrap();
assert_eq!(
created.settings.image.dockerfile,
Some(DockerfileSource::Inline("FROM alpine\n".to_string()))
);
assert!(persisted.contains("FROM alpine"));
assert!(!persisted.contains("path ="));
}
}