1use 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 #[error("trust root: {0}")]
73 TrustRoot(#[from] super::trust_root::TrustRootError),
74 #[error("operator key: {0}")]
78 OperatorKey(#[from] crate::operator_key::OperatorKeyError),
79 #[error("conflict: {0}")]
84 Conflict(String),
85 #[error("invalid argument: {0}")]
91 InvalidArgument(String),
92 #[error("not found: {0}")]
99 DependentNotFound(String),
100 #[error(transparent)]
112 Lifecycle(Box<super::LifecycleError>),
113 #[error("revenue policy: {0}")]
118 RevenuePolicy(#[from] super::BundleDeploymentError),
119 #[error("unauthorized: {reason} (policy `{policy}`)")]
126 Unauthorized { policy: String, reason: String },
127 #[error("not yet implemented: {0}")]
132 NotYetImplemented(String),
133 #[error(transparent)]
147 CommittedAfterSave(Box<StoreError>),
148}
149
150impl StoreError {
151 pub fn is_committed_after_save(&self) -> bool {
158 matches!(self, StoreError::CommittedAfterSave(_))
159 }
160}
161
162fn 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 if s.contains('/') || s.contains('\\') || s.contains(':') || s.contains('\0') {
174 return Err(StoreError::UnsafeEnvId(env_id.clone()));
175 }
176 Ok(s)
177}
178
179pub 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 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 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 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 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 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 fn messaging_dir(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
313 Ok(self.env_dir(env_id)?.join("messaging"))
314 }
315
316 fn messaging_index_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
319 Ok(self.messaging_dir(env_id)?.join("index.json"))
320 }
321
322 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 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 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 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 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
497impl LocalFsStore {
505 fn save_locked(&self, env: &Environment) -> Result<(), StoreError> {
506 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 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 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 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 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 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 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 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 pub fn env_lock_path(&self, env_id: &EnvId) -> Result<PathBuf, StoreError> {
692 self.lock_path(env_id)
693 }
694
695 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#[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 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 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}