Skip to main content

greentic_deployer/environment/
store.rs

1//! [`EnvironmentStore`] trait + [`LocalFsStore`] filesystem implementation
2//! per `plans/next-gen-deployment.md` A2.
3//!
4//! On-disk layout (single env):
5//!
6//! ```text
7//! <root>/<env_id>/
8//!   .lock                              ← per-env exclusive flock target
9//!   environment.json                   ← Environment compose-view (§5.1)
10//!   runtime.json                       ← EnvironmentRuntime (§5.1a, optional)
11//!   runtime-config.json                ← materialized runtime-config.v1 (§5.7, optional)
12//!   env-packs/<slot>/answers.json      ← per-slot opaque answers
13//!   backups/
14//!     environment.json.<ts>.bak
15//!     runtime.json.<ts>.bak
16//!     env-packs/<slot>/answers.json.<ts>.bak
17//! ```
18//!
19//! Every mutation:
20//! 1. acquires the env's flock,
21//! 2. copies the current file (if any) into `backups/` with a UTC timestamp,
22//! 3. validates the in-memory value (where applicable),
23//! 4. atomically rewrites the target via [`atomic_write_json`].
24//!
25//! Generations/ETags/compare-and-swap are NOT implemented here — those are
26//! the remote-store contract (A8). Local FS coordinates via flock only.
27
28use std::path::{Path, PathBuf};
29
30use greentic_deploy_spec::{
31    CapabilitySlot, EnvId, Environment, EnvironmentRuntime, MessagingEndpoint, MessagingEndpointId,
32    RuntimeConfig, SchemaVersion, SpecError, UpdateChannelConfig,
33};
34use serde_json::Value;
35use thiserror::Error;
36
37use super::atomic_write::{AtomicWriteError, atomic_write_json, copy_to_backup};
38use super::file_lock::{EnvFlock, LockError};
39
40#[derive(Debug, Error)]
41pub enum StoreError {
42    #[error("environment `{0}` not found")]
43    NotFound(EnvId),
44    #[error("environment_id mismatch: file is `{file}`, value is `{value}`")]
45    EnvIdMismatch { file: EnvId, value: EnvId },
46    #[error(
47        "environment id `{0}` is not safe as a path segment (rejects \"\", \".\", \"..\", and ids containing path separators)"
48    )]
49    UnsafeEnvId(EnvId),
50    #[error("spec validation failed: {0}")]
51    Spec(#[from] SpecError),
52    #[error(transparent)]
53    Lock(#[from] LockError),
54    #[error(transparent)]
55    AtomicWrite(#[from] AtomicWriteError),
56    #[error("io error on {path}: {source}")]
57    Io {
58        path: PathBuf,
59        #[source]
60        source: std::io::Error,
61    },
62    #[error("json error on {path}: {source}")]
63    Json {
64        path: PathBuf,
65        #[source]
66        source: serde_json::Error,
67    },
68    /// Surfaces from trust-root verbs on [`crate::environment::mutations`] —
69    /// the typed-verb shape returns `StoreError` so the HTTP backend can map
70    /// transport errors into the same enum. CLI callers preserve the
71    /// trust-root noun by downcasting this variant back to `OpError::TrustRoot`.
72    #[error("trust root: {0}")]
73    TrustRoot(#[from] super::trust_root::TrustRootError),
74    /// Surfaces from `bootstrap_trust_root` / `seed_trust_root_if_absent` when
75    /// loading or generating `~/.greentic/operator/key.pem` fails. Distinct
76    /// from `TrustRoot` so CLI callers can surface the right error noun.
77    #[error("operator key: {0}")]
78    OperatorKey(#[from] crate::operator_key::OperatorKeyError),
79    /// Mutator-precondition violation surfaced by [`super::mutations`] verbs
80    /// (e.g. [`super::LocalFsStore::create_environment`] when the env already
81    /// exists). CLI callers preserve the `conflict` noun by downcasting in
82    /// [`crate::cli::map_store_err_preserving_noun`].
83    #[error("conflict: {0}")]
84    Conflict(String),
85    /// Caller-supplied argument is structurally valid but semantically wrong
86    /// (e.g. a `pack_id` that does not appear in any current revision, or a
87    /// welcome-flow bundle that is not linked). CLI callers preserve the
88    /// `invalid-argument` noun via
89    /// [`crate::cli::map_store_err_preserving_noun`].
90    #[error("invalid argument: {0}")]
91    InvalidArgument(String),
92    /// Sub-entity (e.g. a `BundleDeployment`, `EnvPackBinding`,
93    /// `MessagingEndpoint`) is missing under an existing env. Distinct from
94    /// [`StoreError::NotFound`] which is reserved for missing envs. The CLI
95    /// mapper preserves the `not-found` noun by downcasting; the string
96    /// carries the verbatim caller-facing message ("deployment `<id>` not
97    /// found in env `<env>`", etc.) so backend impls don't have to reconstruct it.
98    #[error("not found: {0}")]
99    DependentNotFound(String),
100    /// Revision-lifecycle failure surfaced by the typed
101    /// `warm_revision` / `drain_revision` / `archive_revision` verbs
102    /// (PR-3a.6). The inner [`super::LifecycleError`] preserves the
103    /// structured detail (`HealthGateFailed::failed_checks`,
104    /// `Conflict::expected_starts`, `ActiveTrafficReference::splits`)
105    /// the CLI renders to the operator. No `#[from]` impl: the cycle
106    /// between `LifecycleError::Store(StoreError)` and this variant is
107    /// broken in [`super::mutations_local`] by unwrapping
108    /// `LifecycleError::Store` back into the inner `StoreError` at the
109    /// boundary. CLI callers re-extract via
110    /// [`crate::cli::map_store_err_preserving_noun`].
111    #[error(transparent)]
112    Lifecycle(Box<super::LifecycleError>),
113    /// Revenue-policy sidecar write failure (PR-3a.7b). Surfaces the
114    /// inner [`super::BundleDeploymentError`] so CLI callers can
115    /// downcast to `OpError::RevenuePolicy` via
116    /// [`crate::cli::map_store_err_preserving_noun`].
117    #[error("revenue policy: {0}")]
118    RevenuePolicy(#[from] super::BundleDeploymentError),
119    /// RBAC denial surfaced by a remote store backend (`403` on the A8
120    /// wire, [`greentic_deploy_spec::RemoteStoreError::Unauthorized`]).
121    /// Local-FS verbs never construct it — local RBAC runs in the CLI's
122    /// audit boundary before the verb fires. CLI callers preserve the
123    /// `unauthorized` noun via [`crate::cli::map_store_err_preserving_noun`]
124    /// (PR-4.0; previously flattened into [`StoreError::Conflict`]).
125    #[error("unauthorized: {reason} (policy `{policy}`)")]
126    Unauthorized { policy: String, reason: String },
127    /// Verb recognized but not implemented by the backend (`501` on the A8
128    /// wire, [`greentic_deploy_spec::RemoteStoreError::NotYetImplemented`]).
129    /// CLI callers preserve the `not-yet-implemented` noun via
130    /// [`crate::cli::map_store_err_preserving_noun`] (PR-4.0).
131    #[error("not yet implemented: {0}")]
132    NotYetImplemented(String),
133    /// A typed-verb body persisted state to disk via the lifecycle
134    /// helper's internal `locked.save(...)` and *then* failed on a
135    /// post-save step (env reload, materialized-runtime-config refresh,
136    /// etc.). The wrapped `StoreError` is the original failure; CLI
137    /// callers MUST treat the verb as `committed` (so the audit
138    /// boundary fails-closed on an audit-append failure rather than
139    /// demoting it to `tracing::warn!`) AND forward the inner error.
140    /// The closure-based revision-transition path already had this
141    /// invariant — surfacing it across the typed-verb boundary keeps
142    /// the committed-on-error contract intact (`warm` test
143    /// `warm_ok_with_refresh_failure_and_audit_failure_returns_audit_error`).
144    /// HTTP backends (PR-3b) wrap any "we wrote, then the response
145    /// pipeline failed" error in this variant for the same reason.
146    #[error(transparent)]
147    CommittedAfterSave(Box<StoreError>),
148}
149
150impl StoreError {
151    /// `true` iff `self` is the [`StoreError::CommittedAfterSave`] wrapper.
152    /// Typed-verb callers (`cli::revisions::typed_transition`) query this
153    /// BEFORE mapping the error to an `OpError` so they can call
154    /// [`crate::cli::CommitMarker::mark_committed`] on the boundary — the
155    /// wrapper itself is unwrapped one layer at a time by
156    /// [`crate::cli::map_store_err_preserving_noun`].
157    pub fn is_committed_after_save(&self) -> bool {
158        matches!(self, StoreError::CommittedAfterSave(_))
159    }
160}
161
162/// Reject env ids that, while valid per the upstream `EnvId` validator
163/// (which allows `.` and `..` as full strings), would escape the store
164/// root when used as a path segment. The upstream validator already
165/// rejects `/`, `\`, `:`, and whitespace; this is the narrow gap to close.
166fn safe_env_segment(env_id: &EnvId) -> Result<&str, StoreError> {
167    let s = env_id.as_str();
168    if s.is_empty() || s == "." || s == ".." {
169        return Err(StoreError::UnsafeEnvId(env_id.clone()));
170    }
171    // Defense-in-depth: even though the upstream validator should already
172    // strip these, refuse anything that looks like a multi-segment path.
173    if s.contains('/') || s.contains('\\') || s.contains(':') || s.contains('\0') {
174        return Err(StoreError::UnsafeEnvId(env_id.clone()));
175    }
176    Ok(s)
177}
178
179/// Local-FS persistence contract.
180///
181/// All methods are synchronous. Wrap in `tokio::task::spawn_blocking` at call
182/// sites that need async.
183pub trait EnvironmentStore: Send + Sync {
184    fn list(&self) -> Result<Vec<EnvId>, StoreError>;
185    fn exists(&self, env_id: &EnvId) -> Result<bool, StoreError>;
186
187    fn load(&self, env_id: &EnvId) -> Result<Environment, StoreError>;
188    fn save(&self, env: &Environment) -> Result<(), StoreError>;
189
190    fn load_runtime(&self, env_id: &EnvId) -> Result<Option<EnvironmentRuntime>, StoreError>;
191    fn save_runtime(&self, runtime: &EnvironmentRuntime) -> Result<(), StoreError>;
192
193    fn load_pack_answers(
194        &self,
195        env_id: &EnvId,
196        slot: CapabilitySlot,
197    ) -> Result<Option<Value>, StoreError>;
198    fn save_pack_answers(
199        &self,
200        env_id: &EnvId,
201        slot: CapabilitySlot,
202        answers: &Value,
203    ) -> Result<(), StoreError>;
204    fn delete_pack_answers(&self, env_id: &EnvId, slot: CapabilitySlot) -> Result<(), StoreError>;
205}
206
207#[derive(Debug, Clone)]
208pub struct LocalFsStore {
209    root: PathBuf,
210}
211
212impl LocalFsStore {
213    pub fn new(root: impl Into<PathBuf>) -> Self {
214        Self { root: root.into() }
215    }
216
217    /// `~/.greentic/environments` per the Phase A acceptance criteria.
218    pub fn default_root() -> Option<PathBuf> {
219        dirs_home().map(|h| h.join(".greentic").join("environments"))
220    }
221
222    pub fn root(&self) -> &Path {
223        &self.root
224    }
225
226    pub(crate) fn env_dir(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
227        Ok(self.root.join(safe_env_segment(env_id)?))
228    }
229
230    fn lock_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
231        Ok(self.env_dir(env_id)?.join(".lock"))
232    }
233
234    fn environment_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
235        Ok(self.env_dir(env_id)?.join("environment.json"))
236    }
237
238    fn runtime_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
239        Ok(self.env_dir(env_id)?.join("runtime.json"))
240    }
241
242    /// The materialized `greentic.runtime-config.v1` document that
243    /// `greentic-start` loads at boot. Distinct from `runtime.json` (the
244    /// `EnvironmentRuntime` host-config view).
245    fn runtime_config_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
246        Ok(self.env_dir(env_id)?.join("runtime-config.json"))
247    }
248
249    /// `<env_dir>/update-channel.json` — the operator's update-channel policy
250    /// ([`UpdateChannelConfig`], `§Phase 4`). Optional; an absent file resolves
251    /// to disabled.
252    fn update_channel_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
253        Ok(self.env_dir(env_id)?.join("update-channel.json"))
254    }
255
256    /// Load the operator's update-channel policy. Absent file → `Ok(None)`
257    /// (callers resolve that to deny-by-default). Validates the env-id binding
258    /// and schema discriminator, mirroring [`load_runtime`](EnvironmentStore::load_runtime).
259    /// Inherent (not a trait method): the update channel is a local-runtime
260    /// concern, so remote store backends need not carry it.
261    pub fn load_update_channel(
262        &self,
263        env_id: &EnvId,
264    ) -> Result<Option<UpdateChannelConfig>, StoreError> {
265        let path = self.update_channel_path(env_id)?;
266        if !path.exists() {
267            return Ok(None);
268        }
269        let cfg: UpdateChannelConfig = self.read_json(&path)?;
270        if cfg.environment_id != *env_id {
271            return Err(StoreError::EnvIdMismatch {
272                file: env_id.clone(),
273                value: cfg.environment_id,
274            });
275        }
276        if cfg.schema.as_str() != SchemaVersion::UPDATE_CHANNEL_V1 {
277            return Err(StoreError::Spec(SpecError::SchemaMismatch {
278                expected: SchemaVersion::UPDATE_CHANNEL_V1,
279                actual: cfg.schema.as_str().to_string(),
280            }));
281        }
282        Ok(Some(cfg))
283    }
284
285    /// Write the update-channel policy WITHOUT acquiring the env flock — the
286    /// caller must already hold it (via [`LocalFsStore::transact`] / [`Locked`]).
287    /// Backs up any prior file first, then writes atomically. There is
288    /// deliberately no unlocked public `save_update_channel`: the sidecar is
289    /// only ever written inside a locked transaction, so the read-modify-write
290    /// in `op updates config-set` is free of lost updates (mirrors
291    /// `save_runtime_locked`).
292    fn save_update_channel_locked(&self, cfg: &UpdateChannelConfig) -> Result<(), StoreError> {
293        let target = self.update_channel_path(&cfg.environment_id)?;
294        copy_to_backup(&target, &self.backups_dir(&cfg.environment_id)?)?;
295        atomic_write_json(&target, cfg)?;
296        Ok(())
297    }
298
299    fn pack_answers_path(
300        &self,
301        env_id: &EnvId,
302        slot: CapabilitySlot,
303    ) -> Result<PathBuf, StoreError> {
304        Ok(self
305            .env_dir(env_id)?
306            .join("env-packs")
307            .join(slot.as_str())
308            .join("answers.json"))
309    }
310
311    /// `<env_dir>/messaging/` — directory holding per-endpoint projections.
312    fn messaging_dir(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
313        Ok(self.env_dir(env_id)?.join("messaging"))
314    }
315
316    /// `<env_dir>/messaging/index.json` — projection enumerating every
317    /// endpoint in source-of-truth order.
318    fn messaging_index_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
319        Ok(self.messaging_dir(env_id)?.join("index.json"))
320    }
321
322    /// `<env_dir>/messaging/<endpoint_id>.json` — per-endpoint projection.
323    fn messaging_endpoint_path(
324        &self,
325        env_id: &EnvId,
326        endpoint_id: &MessagingEndpointId,
327    ) -> Result<PathBuf, StoreError> {
328        Ok(self
329            .messaging_dir(env_id)?
330            .join(format!("{endpoint_id}.json")))
331    }
332
333    /// Subdirectory under `backups/` for messaging projection snapshots.
334    fn messaging_backups_dir(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
335        Ok(self.backups_dir(env_id)?.join("messaging"))
336    }
337
338    fn backups_dir(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
339        Ok(self.env_dir(env_id)?.join("backups"))
340    }
341
342    fn pack_backups_dir(
343        &self,
344        env_id: &EnvId,
345        slot: CapabilitySlot,
346    ) -> Result<PathBuf, StoreError> {
347        Ok(self
348            .backups_dir(env_id)?
349            .join("env-packs")
350            .join(slot.as_str()))
351    }
352
353    fn read_json<T: serde::de::DeserializeOwned>(&self, path: &Path) -> Result<T, StoreError> {
354        let bytes = std::fs::read(path).map_err(|source| StoreError::Io {
355            path: path.to_path_buf(),
356            source,
357        })?;
358        serde_json::from_slice(&bytes).map_err(|source| StoreError::Json {
359            path: path.to_path_buf(),
360            source,
361        })
362    }
363}
364
365impl EnvironmentStore for LocalFsStore {
366    fn list(&self) -> Result<Vec<EnvId>, StoreError> {
367        if !self.root.exists() {
368            return Ok(vec![]);
369        }
370        let mut out = Vec::new();
371        for entry in std::fs::read_dir(&self.root).map_err(|source| StoreError::Io {
372            path: self.root.clone(),
373            source,
374        })? {
375            let entry = entry.map_err(|source| StoreError::Io {
376                path: self.root.clone(),
377                source,
378            })?;
379            let path = entry.path();
380            if !path.is_dir() {
381                continue;
382            }
383            let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
384                continue;
385            };
386            let Ok(id) = EnvId::try_from(name) else {
387                continue;
388            };
389            // Skip ids that escape the root segment (e.g. `.` or `..`).
390            if safe_env_segment(&id).is_err() {
391                continue;
392            }
393            let env_path = path.join("environment.json");
394            if !env_path.exists() {
395                continue;
396            }
397            // Silently skip envs whose on-disk file is unreadable, fails to
398            // deserialize, or carries a mismatched `environment_id`. Without
399            // this check a corrupted document could surface in `list()` under
400            // a name that does not match its own contents.
401            let Ok(env) = self.read_json::<Environment>(&env_path) else {
402                continue;
403            };
404            if env.environment_id != id {
405                continue;
406            }
407            out.push(id);
408        }
409        out.sort_by(|a, b| a.as_str().cmp(b.as_str()));
410        Ok(out)
411    }
412
413    fn exists(&self, env_id: &EnvId) -> Result<bool, StoreError> {
414        Ok(self.environment_path(env_id)?.exists())
415    }
416
417    fn load(&self, env_id: &EnvId) -> Result<Environment, StoreError> {
418        let path = self.environment_path(env_id)?;
419        if !path.exists() {
420            return Err(StoreError::NotFound(env_id.clone()));
421        }
422        let env: Environment = self.read_json(&path)?;
423        if env.environment_id != *env_id {
424            return Err(StoreError::EnvIdMismatch {
425                file: env_id.clone(),
426                value: env.environment_id,
427            });
428        }
429        env.validate()?;
430        Ok(env)
431    }
432
433    fn save(&self, env: &Environment) -> Result<(), StoreError> {
434        // Validate first so a bad value never touches disk and never claims
435        // the lock from another writer.
436        env.validate()?;
437        let env_id = &env.environment_id;
438        let _guard = EnvFlock::acquire(&self.lock_path(env_id)?)?;
439        self.save_locked(env)
440    }
441
442    fn load_runtime(&self, env_id: &EnvId) -> Result<Option<EnvironmentRuntime>, StoreError> {
443        let path = self.runtime_path(env_id)?;
444        if !path.exists() {
445            return Ok(None);
446        }
447        let runtime: EnvironmentRuntime = self.read_json(&path)?;
448        if runtime.environment_id != *env_id {
449            return Err(StoreError::EnvIdMismatch {
450                file: env_id.clone(),
451                value: runtime.environment_id,
452            });
453        }
454        if runtime.schema.as_str() != SchemaVersion::ENVIRONMENT_RUNTIME_V1 {
455            return Err(StoreError::Spec(SpecError::SchemaMismatch {
456                expected: SchemaVersion::ENVIRONMENT_RUNTIME_V1,
457                actual: runtime.schema.as_str().to_string(),
458            }));
459        }
460        Ok(Some(runtime))
461    }
462
463    fn save_runtime(&self, runtime: &EnvironmentRuntime) -> Result<(), StoreError> {
464        let env_id = &runtime.environment_id;
465        let _guard = EnvFlock::acquire(&self.lock_path(env_id)?)?;
466        self.save_runtime_locked(runtime)
467    }
468
469    fn load_pack_answers(
470        &self,
471        env_id: &EnvId,
472        slot: CapabilitySlot,
473    ) -> Result<Option<Value>, StoreError> {
474        let path = self.pack_answers_path(env_id, slot)?;
475        if !path.exists() {
476            return Ok(None);
477        }
478        Ok(Some(self.read_json(&path)?))
479    }
480
481    fn save_pack_answers(
482        &self,
483        env_id: &EnvId,
484        slot: CapabilitySlot,
485        answers: &Value,
486    ) -> Result<(), StoreError> {
487        let _guard = EnvFlock::acquire(&self.lock_path(env_id)?)?;
488        self.save_pack_answers_locked(env_id, slot, answers)
489    }
490
491    fn delete_pack_answers(&self, env_id: &EnvId, slot: CapabilitySlot) -> Result<(), StoreError> {
492        let _guard = EnvFlock::acquire(&self.lock_path(env_id)?)?;
493        self.delete_pack_answers_locked(env_id, slot)
494    }
495}
496
497// --- Locked-but-no-relock inner helpers ----------------------------------
498//
499// Each `save_*` and `delete_*` on `LocalFsStore` extracts to a `_locked`
500// inner that assumes the caller already holds the env flock. The trait
501// methods take the lock themselves; [`LocalFsStore::transact`] takes it
502// once and dispatches through [`Locked`].
503
504impl LocalFsStore {
505    fn save_locked(&self, env: &Environment) -> Result<(), StoreError> {
506        // Discriminator double-check (validate() also covers this, but the
507        // explicit error surface here is clearer).
508        if env.schema.as_str() != SchemaVersion::ENVIRONMENT_V1 {
509            return Err(StoreError::Spec(SpecError::SchemaMismatch {
510                expected: SchemaVersion::ENVIRONMENT_V1,
511                actual: env.schema.as_str().to_string(),
512            }));
513        }
514        env.validate()?;
515        let env_id = &env.environment_id;
516        let target = self.environment_path(env_id)?;
517        copy_to_backup(&target, &self.backups_dir(env_id)?)?;
518        atomic_write_json(&target, env)?;
519        Ok(())
520    }
521
522    fn save_runtime_locked(&self, runtime: &EnvironmentRuntime) -> Result<(), StoreError> {
523        if runtime.schema.as_str() != SchemaVersion::ENVIRONMENT_RUNTIME_V1 {
524            return Err(StoreError::Spec(SpecError::SchemaMismatch {
525                expected: SchemaVersion::ENVIRONMENT_RUNTIME_V1,
526                actual: runtime.schema.as_str().to_string(),
527            }));
528        }
529        let env_id = &runtime.environment_id;
530        let target = self.runtime_path(env_id)?;
531        copy_to_backup(&target, &self.backups_dir(env_id)?)?;
532        atomic_write_json(&target, runtime)?;
533        Ok(())
534    }
535
536    /// Write the materialized runtime-config, skipping the backup+write when
537    /// the on-disk file already matches. Re-materialization runs after every
538    /// traffic mutation, but the projection only changes when `traffic_splits`
539    /// does — the change-detection guard keeps no-op refreshes from churning
540    /// backups.
541    fn save_runtime_config_locked(&self, cfg: &RuntimeConfig) -> Result<(), StoreError> {
542        let env_id = &cfg.env_id;
543        let target = self.runtime_config_path(env_id)?;
544        if let Ok(existing) = self.read_json::<RuntimeConfig>(&target)
545            && &existing == cfg
546        {
547            return Ok(());
548        }
549        copy_to_backup(&target, &self.backups_dir(env_id)?)?;
550        atomic_write_json(&target, cfg)?;
551        Ok(())
552    }
553
554    /// Remove the runtime-config when no split routes any revision. B0 rejects
555    /// a config with an empty `revisions` list, so absence — not an empty file
556    /// — is the correct "nothing live" signal.
557    fn delete_runtime_config_locked(&self, env_id: &EnvId) -> Result<(), StoreError> {
558        let target = self.runtime_config_path(env_id)?;
559        if target.exists() {
560            copy_to_backup(&target, &self.backups_dir(env_id)?)?;
561            std::fs::remove_file(&target).map_err(|source| StoreError::Io {
562                path: target,
563                source,
564            })?;
565        }
566        Ok(())
567    }
568
569    /// Reconcile `<env_dir>/messaging/` against the just-saved env's
570    /// `messaging_endpoints`. Writes one `<endpoint_id>.json` per endpoint
571    /// (skipping no-op rewrites) plus an `index.json` enumerator, and removes
572    /// per-endpoint files whose endpoint is no longer in the env.
573    ///
574    /// When the env has zero endpoints, the directory is left empty (after
575    /// removing stale files) and `index.json` is deleted — absence is the
576    /// "no endpoints" signal, mirroring the `runtime-config.json` precedent.
577    fn refresh_messaging_locked(&self, env: &Environment) -> Result<(), StoreError> {
578        let env_id = &env.environment_id;
579        let dir = self.messaging_dir(env_id)?;
580        // Snapshot existing endpoint files so we can prune anything not in
581        // the new endpoint set. Pre-existing non-endpoint files (`index.json`,
582        // unrelated dotfiles) are skipped.
583        let mut existing_files: Vec<(MessagingEndpointId, PathBuf)> = Vec::new();
584        if dir.exists() {
585            for entry in std::fs::read_dir(&dir).map_err(|source| StoreError::Io {
586                path: dir.clone(),
587                source,
588            })? {
589                let entry = entry.map_err(|source| StoreError::Io {
590                    path: dir.clone(),
591                    source,
592                })?;
593                let path = entry.path();
594                let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
595                    continue;
596                };
597                if path.extension().and_then(|e| e.to_str()) != Some("json") {
598                    continue;
599                }
600                if stem == "index" {
601                    continue;
602                }
603                let Ok(ulid) = stem.parse::<ulid::Ulid>() else {
604                    continue;
605                };
606                existing_files.push((MessagingEndpointId(ulid), path));
607            }
608        }
609        // Write each endpoint; track ids written so we can prune the rest.
610        let mut written_ids: std::collections::HashSet<MessagingEndpointId> =
611            std::collections::HashSet::with_capacity(env.messaging_endpoints.len());
612        for endpoint in &env.messaging_endpoints {
613            written_ids.insert(endpoint.endpoint_id);
614            let target = self.messaging_endpoint_path(env_id, &endpoint.endpoint_id)?;
615            if let Ok(existing) = self.read_json::<MessagingEndpoint>(&target)
616                && existing == *endpoint
617            {
618                continue;
619            }
620            copy_to_backup(&target, &self.messaging_backups_dir(env_id)?)?;
621            atomic_write_json(&target, endpoint)?;
622        }
623        for (id, path) in existing_files {
624            if !written_ids.contains(&id) {
625                copy_to_backup(&path, &self.messaging_backups_dir(env_id)?)?;
626                std::fs::remove_file(&path).map_err(|source| StoreError::Io {
627                    path: path.clone(),
628                    source,
629                })?;
630            }
631        }
632        // Index file: rewrite when non-empty, delete when empty.
633        let index_path = self.messaging_index_path(env_id)?;
634        let index = super::messaging::materialize_messaging_index(env);
635        if index.is_empty() {
636            if index_path.exists() {
637                copy_to_backup(&index_path, &self.messaging_backups_dir(env_id)?)?;
638                std::fs::remove_file(&index_path).map_err(|source| StoreError::Io {
639                    path: index_path,
640                    source,
641                })?;
642            }
643        } else {
644            if let Ok(existing) =
645                self.read_json::<Vec<super::messaging::MessagingEndpointIndexEntry>>(&index_path)
646                && existing == index
647            {
648                return Ok(());
649            }
650            copy_to_backup(&index_path, &self.messaging_backups_dir(env_id)?)?;
651            atomic_write_json(&index_path, &index)?;
652        }
653        Ok(())
654    }
655
656    fn save_pack_answers_locked(
657        &self,
658        env_id: &EnvId,
659        slot: CapabilitySlot,
660        answers: &Value,
661    ) -> Result<(), StoreError> {
662        let target = self.pack_answers_path(env_id, slot)?;
663        copy_to_backup(&target, &self.pack_backups_dir(env_id, slot)?)?;
664        atomic_write_json(&target, answers)?;
665        Ok(())
666    }
667
668    fn delete_pack_answers_locked(
669        &self,
670        env_id: &EnvId,
671        slot: CapabilitySlot,
672    ) -> Result<(), StoreError> {
673        let target = self.pack_answers_path(env_id, slot)?;
674        if target.exists() {
675            // Snapshot before removal so the previous answers can be recovered.
676            copy_to_backup(&target, &self.pack_backups_dir(env_id, slot)?)?;
677            std::fs::remove_file(&target).map_err(|source| StoreError::Io {
678                path: target,
679                source,
680            })?;
681        }
682        Ok(())
683    }
684
685    /// Resolve the per-env lock path. Public so external callers that need
686    /// raw `EnvFlock` semantics can grab the path without poking at private
687    /// internals — but they must understand that each mutating call already
688    /// re-acquires this lock blocking, so externally-held guards combined
689    /// with `save_*` on the same instance will deadlock. Prefer
690    /// [`LocalFsStore::transact`] for compound mutations.
691    pub fn env_lock_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
692        self.lock_path(env_id)
693    }
694
695    /// Run `f` while holding the env's exclusive lock. The closure receives
696    /// a [`Locked`] view whose mutator methods skip lock acquisition, so a
697    /// natural `load → mutate → save` flow inside the closure does not
698    /// re-enter (and deadlock on) the per-FD flock.
699    ///
700    /// Reads (`load`, `load_runtime`, `load_pack_answers`, `exists`,
701    /// `list`) do not take the lock and are also available on the
702    /// `Locked` handle for convenience.
703    ///
704    /// Generic over the closure's error type via `E: From<StoreError>` so
705    /// callers that mix storage errors with their own domain errors (e.g.
706    /// the `cli::*` operator surface using `OpError`) can run validation +
707    /// load + mutate + save in a single critical section without an
708    /// outer-layer error-mapping dance. Existing callers passing
709    /// `Result<_, StoreError>` continue to work because
710    /// `From<StoreError> for StoreError` is automatic.
711    pub fn transact<F, R, E>(&self, env_id: &EnvId, f: F) -> Result<R, E>
712    where
713        F: FnOnce(&Locked<'_>) -> Result<R, E>,
714        E: From<StoreError>,
715    {
716        let lock_path = self.lock_path(env_id).map_err(E::from)?;
717        let _guard = EnvFlock::acquire(&lock_path).map_err(|e| E::from(StoreError::Lock(e)))?;
718        let locked = Locked {
719            store: self,
720            env_id: env_id.clone(),
721        };
722        f(&locked)
723    }
724}
725
726/// Lock-holding handle returned by [`LocalFsStore::transact`]. All mutator
727/// methods on this type skip lock acquisition; the lock is held by the
728/// enclosing `transact` scope and released on its return.
729///
730/// Mutators on this handle reject writes whose embedded `environment_id`
731/// (or runtime `environment_id`) does not match the env the transaction is
732/// scoped to — the lock guards exactly one env's state, so accidentally
733/// writing a different env's payload inside the closure would bypass that
734/// env's flock entirely.
735#[derive(Debug)]
736pub struct Locked<'a> {
737    store: &'a LocalFsStore,
738    env_id: EnvId,
739}
740
741impl<'a> Locked<'a> {
742    pub fn env_id(&self) -> &EnvId {
743        &self.env_id
744    }
745
746    pub fn load(&self) -> Result<Environment, StoreError> {
747        self.store.load(&self.env_id)
748    }
749
750    pub fn save(&self, env: &Environment) -> Result<(), StoreError> {
751        if env.environment_id != self.env_id {
752            return Err(StoreError::EnvIdMismatch {
753                file: self.env_id.clone(),
754                value: env.environment_id.clone(),
755            });
756        }
757        self.store.save_locked(env)
758    }
759
760    pub fn load_runtime(&self) -> Result<Option<EnvironmentRuntime>, StoreError> {
761        self.store.load_runtime(&self.env_id)
762    }
763
764    pub fn save_runtime(&self, runtime: &EnvironmentRuntime) -> Result<(), StoreError> {
765        if runtime.environment_id != self.env_id {
766            return Err(StoreError::EnvIdMismatch {
767                file: self.env_id.clone(),
768                value: runtime.environment_id.clone(),
769            });
770        }
771        self.store.save_runtime_locked(runtime)
772    }
773
774    pub fn load_update_channel(&self) -> Result<Option<UpdateChannelConfig>, StoreError> {
775        self.store.load_update_channel(&self.env_id)
776    }
777
778    pub fn save_update_channel(&self, cfg: &UpdateChannelConfig) -> Result<(), StoreError> {
779        if cfg.environment_id != self.env_id {
780            return Err(StoreError::EnvIdMismatch {
781                file: self.env_id.clone(),
782                value: cfg.environment_id.clone(),
783            });
784        }
785        self.store.save_update_channel_locked(cfg)
786    }
787
788    pub fn load_pack_answers(&self, slot: CapabilitySlot) -> Result<Option<Value>, StoreError> {
789        self.store.load_pack_answers(&self.env_id, slot)
790    }
791
792    pub fn save_pack_answers(
793        &self,
794        slot: CapabilitySlot,
795        answers: &Value,
796    ) -> Result<(), StoreError> {
797        self.store
798            .save_pack_answers_locked(&self.env_id, slot, answers)
799    }
800
801    pub fn delete_pack_answers(&self, slot: CapabilitySlot) -> Result<(), StoreError> {
802        self.store.delete_pack_answers_locked(&self.env_id, slot)
803    }
804
805    /// Re-materialize `runtime-config.json` from `env`'s current traffic
806    /// splits. Call with the just-saved env after any mutation that can change
807    /// the `TrafficSplit` set, inside the same `transact` scope, so the file
808    /// `greentic-start` boots from stays in lock-step with `environment.json`.
809    /// Writes the projection, or deletes the file when no split routes a
810    /// revision — `greentic-start` rejects a config with an empty `revisions`
811    /// list, so absence is the correct "nothing live" signal.
812    pub fn refresh_runtime_config(&self, env: &Environment) -> Result<(), StoreError> {
813        let cfg = super::runtime_config::materialize_runtime_config(env);
814        if cfg.revisions.is_empty() {
815            self.store.delete_runtime_config_locked(&self.env_id)
816        } else {
817            self.store.save_runtime_config_locked(&cfg)
818        }
819    }
820
821    /// Reconcile `<env_dir>/messaging/` against the just-saved env. Call
822    /// after any mutation that can change `Environment.messaging_endpoints`
823    /// (M1.2 add/update/remove verbs). Writes one file per endpoint plus an
824    /// `index.json` enumerator; per-endpoint files for ids no longer in the
825    /// env are pruned (with backup) and `index.json` is removed when the env
826    /// has zero endpoints.
827    pub fn refresh_messaging_projection(&self, env: &Environment) -> Result<(), StoreError> {
828        if env.environment_id != self.env_id {
829            return Err(StoreError::EnvIdMismatch {
830                file: self.env_id.clone(),
831                value: env.environment_id.clone(),
832            });
833        }
834        self.store.refresh_messaging_locked(env)
835    }
836}
837
838#[cfg(unix)]
839pub(crate) fn dirs_home() -> Option<PathBuf> {
840    std::env::var_os("HOME").map(PathBuf::from)
841}
842
843#[cfg(windows)]
844pub(crate) fn dirs_home() -> Option<PathBuf> {
845    std::env::var_os("USERPROFILE").map(PathBuf::from)
846}
847
848#[cfg(not(any(unix, windows)))]
849pub(crate) fn dirs_home() -> Option<PathBuf> {
850    None
851}