1use std::path::Path;
15
16use chrono::Utc;
17use greentic_distributor_client::signing::TrustedKey;
18
19use greentic_deploy_spec::engine::{self, EngineError};
20use greentic_deploy_spec::{
21 BundleDeployment, BundleId, CapabilitySlot, DeploymentId, EnvId, EnvPackBinding, Environment,
22 EnvironmentHostConfig, ExtensionBinding, HealthStatus, IdempotencyKey, MessagingEndpoint,
23 MessagingEndpointId, RetentionPolicy, Revision, RevisionId, RevocationConfig, SchemaVersion,
24 SecretRef,
25};
26
27use super::bootstrap::{
28 EnsureLocalEnvironmentPayload, LocalEnvOutcome, fill_missing_default_bindings,
29};
30use super::lifecycle::LifecycleError;
31use super::mutations::{
32 AddBundlePayload, AddMessagingEndpointPayload, ApplyTrafficSplitOutcome, EnvironmentMutations,
33 ExtensionKey, MigrateMergePayload, RemoveBundleOutcome, RevisionTransitionOutcome,
34 RollbackTrafficSplitOutcome, SetMessagingWelcomeFlowPayload, SetTrafficSplitPayload,
35 StageRevisionPayload, TrustRootAddOutcome, TrustRootRemoveOutcome, TrustRootSeed,
36 UpdateBundlePayload, UpdateEnvironmentPayload, WarmRevisionPayload,
37};
38use super::store::{EnvironmentStore, LocalFsStore, StoreError};
39use super::trust_root::{self as store_trust_root, trust_root_path};
40
41fn fold_lifecycle_err(err: LifecycleError) -> StoreError {
44 match err {
45 LifecycleError::Store(inner) => inner,
46 other => StoreError::Lifecycle(Box::new(other)),
47 }
48}
49
50fn map_engine_err(err: EngineError) -> StoreError {
55 match err {
56 EngineError::NotFound(id) => StoreError::NotFound(id),
57 }
58}
59
60fn map_traffic_err(err: engine::TrafficSplitError) -> StoreError {
68 use engine::TrafficSplitError as E;
69 match err {
70 E::DeploymentNotFound { .. }
71 | E::RevisionNotFound { .. }
72 | E::NoSplit { .. }
73 | E::SnapshotMissing { .. } => StoreError::DependentNotFound(err.to_string()),
74 E::WrongDeployment { .. }
75 | E::IdempotencyKeyReused { .. }
76 | E::AdmissionRevisionMissing { .. }
77 | E::NotReady { .. }
78 | E::SnapshotEncode { .. }
79 | E::NoPreviousSnapshot { .. }
80 | E::SnapshotDecode { .. } => StoreError::Conflict(err.to_string()),
81 E::Invalid(spec) => StoreError::Spec(spec),
82 }
83}
84
85fn map_binding_err(err: engine::BindingError) -> StoreError {
92 use engine::BindingError as E;
93 match err {
94 E::SlotNotBound { .. }
95 | E::ExtensionNotBound { .. }
96 | E::SlotSnapshotMissing { .. }
97 | E::ExtensionSnapshotMissing { .. } => StoreError::DependentNotFound(err.to_string()),
98 E::SlotAlreadyBound { .. }
99 | E::SlotMismatch { .. }
100 | E::NotPackSlot { .. }
101 | E::SlotNoPrevious { .. }
102 | E::SlotGenerationOverflow { .. }
103 | E::ExtensionAlreadyBound { .. }
104 | E::ExtensionKeyMismatch { .. }
105 | E::ExtensionNoPrevious { .. }
106 | E::ExtensionGenerationOverflow { .. }
107 | E::SnapshotEncode { .. }
108 | E::SnapshotDecode { .. } => StoreError::Conflict(err.to_string()),
109 }
110}
111
112fn map_bundle_err(err: engine::BundleError) -> StoreError {
116 use engine::BundleError as E;
117 match err {
118 E::DeploymentNotFound { .. } => StoreError::DependentNotFound(err.to_string()),
119 E::AlreadyDeployed { .. } | E::StillLive { .. } => StoreError::Conflict(err.to_string()),
120 }
121}
122
123fn map_messaging_err(err: engine::MessagingError) -> StoreError {
128 use engine::MessagingError as E;
129 match err {
130 E::EndpointNotFound { .. } | E::BundleNotDeployed { .. } => {
131 StoreError::DependentNotFound(err.to_string())
132 }
133 E::IdempotencyKeyReuse { .. }
134 | E::EndpointAlreadyExists { .. }
135 | E::WelcomeFlowOwned { .. } => StoreError::Conflict(err.to_string()),
136 E::BundleNotLinked { .. } | E::WelcomePackUnknown { .. } | E::InvalidSecretRef { .. } => {
137 StoreError::InvalidArgument(err.to_string())
138 }
139 E::SecretProvision(message) => StoreError::Conflict(message),
140 }
141}
142
143fn webhook_provision_ctx(env: &Environment) -> (Option<String>, bool) {
153 let owner = env
154 .host_config
155 .tenant_org_id
156 .as_deref()
157 .map(str::trim)
158 .filter(|s| !s.is_empty())
159 .map(str::to_string);
160 (owner, crate::cli::env::secrets_backend_is_dev_store(env))
161}
162
163impl LocalFsStore {
164 pub fn create_environment(
179 &self,
180 env_id: &EnvId,
181 name: String,
182 host_config: EnvironmentHostConfig,
183 ) -> Result<Environment, StoreError> {
184 self.transact(env_id, |locked| {
185 match locked.load() {
192 Ok(_) => {
193 return Err(StoreError::Conflict(format!(
194 "environment `{}` already exists",
195 locked.env_id()
196 )));
197 }
198 Err(StoreError::NotFound(_)) => {}
199 Err(e) => return Err(e),
200 }
201 let env = engine::fresh_environment(
202 locked.env_id(),
203 name,
204 host_config,
205 RevocationConfig::default(),
206 RetentionPolicy::default(),
207 HealthStatus::default(),
208 );
209 locked.save(&env)?;
210 Ok(env)
211 })
212 }
213
214 pub fn update_environment(
225 &self,
226 env_id: &EnvId,
227 patch: UpdateEnvironmentPayload,
228 ) -> Result<Environment, StoreError> {
229 self.transact(env_id, |locked| {
230 let mut env = locked.load()?;
231 engine::apply_environment_update(&mut env, patch);
232 locked.save(&env)?;
233 Ok(env)
234 })
235 }
236
237 pub fn migrate_merge_bindings(
256 &self,
257 target_env_id: &EnvId,
258 payload: MigrateMergePayload,
259 ) -> Result<(Vec<String>, Vec<String>), StoreError> {
260 let MigrateMergePayload {
261 packs,
262 extensions,
263 seed_if_missing,
264 } = payload;
265 self.transact(target_env_id, |locked| {
266 let existing = match locked.load() {
267 Ok(env) => Some(env),
268 Err(StoreError::NotFound(_)) => None,
269 Err(e) => return Err(e),
270 };
271 let mut target_env =
272 engine::seed_or_existing(existing, locked.env_id(), seed_if_missing)
273 .map_err(map_engine_err)?;
274 let report = engine::merge_bindings(&mut target_env, packs, extensions);
280 locked.save(&target_env)?;
281 Ok((report.merged_slots, report.merged_extensions))
282 })
283 }
284
285 pub fn stage_revision(
311 &self,
312 env_id: &EnvId,
313 payload: StageRevisionPayload,
314 _idempotency_key: IdempotencyKey,
315 ) -> Result<Revision, StoreError> {
316 self.transact(env_id, |locked| {
317 let mut env = locked.load()?;
318 let revision = engine::stage_revision(&mut env, payload, Utc::now())
319 .map_err(|err| fold_lifecycle_err(err.into()))?;
320 locked.save(&env)?;
321 Ok(revision)
322 })
323 }
324
325 pub fn warm_revision(
341 &self,
342 env_id: &EnvId,
343 payload: WarmRevisionPayload,
344 _idempotency_key: IdempotencyKey,
345 ) -> Result<RevisionTransitionOutcome, StoreError> {
346 self.run_revision_transition(env_id, |env| {
347 engine::warm_revision(env, payload, Utc::now())
348 })
349 }
350
351 pub fn drain_revision(
358 &self,
359 env_id: &EnvId,
360 revision_id: RevisionId,
361 _idempotency_key: IdempotencyKey,
362 ) -> Result<RevisionTransitionOutcome, StoreError> {
363 self.run_revision_transition(env_id, |env| engine::drain_revision(env, revision_id))
364 }
365
366 pub fn archive_revision(
374 &self,
375 env_id: &EnvId,
376 revision_id: RevisionId,
377 _idempotency_key: IdempotencyKey,
378 ) -> Result<RevisionTransitionOutcome, StoreError> {
379 self.run_revision_transition(env_id, |env| engine::archive_revision(env, revision_id))
380 }
381
382 fn run_revision_transition<F>(
394 &self,
395 env_id: &EnvId,
396 apply: F,
397 ) -> Result<RevisionTransitionOutcome, StoreError>
398 where
399 F: FnOnce(
400 &mut Environment,
401 ) -> Result<engine::RevisionTransition, engine::RevisionLifecycleError>,
402 {
403 self.transact(env_id, |locked| {
404 let mut env = locked.load()?;
405 let transition = match apply(&mut env) {
406 Ok(transition) => {
407 locked.save(&env)?;
408 transition
409 }
410 Err(err) if err.env_mutated() => {
411 locked.save(&env)?;
415 return Err(fold_lifecycle_err(err.into()));
416 }
417 Err(err) => return Err(fold_lifecycle_err(err.into())),
418 };
419 locked
430 .refresh_runtime_config(&env)
431 .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
432 Ok(RevisionTransitionOutcome {
433 revision: transition.revision,
434 environment: env,
435 starting_lifecycle: transition.starting_lifecycle,
436 })
437 })
438 }
439
440 pub fn add_bundle(
455 &self,
456 env_id: &EnvId,
457 payload: AddBundlePayload,
458 _idempotency_key: IdempotencyKey,
459 ) -> Result<BundleDeployment, StoreError> {
460 let env_dir = self.env_dir(env_id)?;
461 self.transact(env_id, |locked| {
462 let mut env = locked.load()?;
463 let idx = engine::add_bundle(
464 &mut env,
465 payload,
466 crate::environment::mint_deployment_id(),
467 Utc::now(),
468 )
469 .map_err(map_bundle_err)?;
470 let operator_key = crate::operator_key::load_existing_only()?;
471 let version = crate::environment::write_revenue_policy_version(
472 &env_dir,
473 &env.bundles[idx],
474 &env.bundles[idx].revenue_share,
475 env.bundles[idx].created_at,
476 &operator_key,
477 )?;
478 env.bundles[idx].revenue_policy_ref = version.policy_ref;
479 locked.save(&env)?;
480 Ok(env.bundles[idx].clone())
481 })
482 }
483
484 pub fn update_bundle(
496 &self,
497 env_id: &EnvId,
498 payload: UpdateBundlePayload,
499 _idempotency_key: IdempotencyKey,
500 ) -> Result<BundleDeployment, StoreError> {
501 let env_dir = self.env_dir(env_id)?;
502 self.transact(env_id, |locked| {
503 let mut env = locked.load()?;
504 let applied = engine::update_bundle(&mut env, payload).map_err(map_bundle_err)?;
505 if applied.revenue_share_changed {
506 let idx = applied.index;
507 let created_at = Utc::now();
508 let operator_key = crate::operator_key::load_existing_only()?;
509 let version = crate::environment::write_revenue_policy_version(
510 &env_dir,
511 &env.bundles[idx],
512 &env.bundles[idx].revenue_share,
513 created_at,
514 &operator_key,
515 )?;
516 env.bundles[idx].revenue_policy_ref = version.policy_ref;
517 }
518 locked.save(&env)?;
519 Ok(env.bundles[applied.index].clone())
520 })
521 }
522
523 pub fn remove_bundle(
538 &self,
539 env_id: &EnvId,
540 deployment_id: DeploymentId,
541 _idempotency_key: IdempotencyKey,
542 ) -> Result<RemoveBundleOutcome, StoreError> {
543 self.transact(env_id, |locked| {
544 let mut env = locked.load()?;
545 let outcome = engine::remove_bundle(&mut env, deployment_id).map_err(map_bundle_err)?;
546 locked.save(&env)?;
547 Ok(outcome)
548 })
549 }
550
551 pub fn add_pack_binding(
564 &self,
565 env_id: &EnvId,
566 binding: EnvPackBinding,
567 _idempotency_key: IdempotencyKey,
568 ) -> Result<EnvPackBinding, StoreError> {
569 self.transact(env_id, |locked| {
570 let mut env = locked.load()?;
571 let added = engine::add_pack_binding(&mut env, binding).map_err(map_binding_err)?;
572 locked.save(&env)?;
573 Ok(added)
574 })
575 }
576
577 pub fn update_pack_binding(
586 &self,
587 env_id: &EnvId,
588 slot: CapabilitySlot,
589 binding: EnvPackBinding,
590 _idempotency_key: IdempotencyKey,
591 ) -> Result<(EnvPackBinding, u64), StoreError> {
592 self.transact(env_id, |locked| {
593 let mut env = locked.load()?;
594 let (updated, new_generation) =
595 engine::update_pack_binding(&mut env, slot, binding).map_err(map_binding_err)?;
596 locked.save(&env)?;
597 Ok((updated, new_generation))
598 })
599 }
600
601 pub fn remove_pack_binding(
607 &self,
608 env_id: &EnvId,
609 slot: CapabilitySlot,
610 _idempotency_key: IdempotencyKey,
611 ) -> Result<(EnvPackBinding, u64), StoreError> {
612 self.transact(env_id, |locked| {
613 let mut env = locked.load()?;
614 let (removed, generation) =
615 engine::remove_pack_binding(&mut env, slot).map_err(map_binding_err)?;
616 locked.save(&env)?;
617 Ok((removed, generation))
618 })
619 }
620
621 pub fn rollback_pack_binding(
630 &self,
631 env_id: &EnvId,
632 slot: CapabilitySlot,
633 _idempotency_key: IdempotencyKey,
634 ) -> Result<(EnvPackBinding, u64), StoreError> {
635 self.transact(env_id, |locked| {
636 let mut env = locked.load()?;
637 let (restored, new_generation) =
638 engine::rollback_pack_binding(&mut env, slot).map_err(map_binding_err)?;
639 locked.save(&env)?;
640 Ok((restored, new_generation))
641 })
642 }
643
644 pub fn bootstrap_trust_root(&self, env_id: &EnvId) -> Result<TrustRootSeed, StoreError> {
663 let op_key = crate::operator_key::load_or_generate()?;
664 let env_dir = self.env_dir(env_id)?;
665 self.transact(env_id, |_locked| seed_op_key(&env_dir, op_key))
666 }
667
668 pub fn seed_trust_root_if_absent(
675 &self,
676 env_id: &EnvId,
677 ) -> Result<Option<TrustRootSeed>, StoreError> {
678 let env_dir = self.env_dir(env_id)?;
679 let tr_path = trust_root_path(&env_dir);
680 self.transact(env_id, |_locked| {
681 if tr_path.exists() {
682 return Ok(None);
683 }
684 let op_key = crate::operator_key::load_or_generate()?;
685 seed_op_key(&env_dir, op_key).map(Some)
686 })
687 }
688
689 pub fn add_trusted_key(
698 &self,
699 env_id: &EnvId,
700 key_id: String,
701 public_key_pem: String,
702 _idempotency_key: IdempotencyKey,
703 ) -> Result<TrustRootAddOutcome, StoreError> {
704 let env_dir = self.env_dir(env_id)?;
705 self.transact(env_id, |_locked| {
706 let trust = store_trust_root::add_trusted_key(
707 &env_dir,
708 TrustedKey {
709 key_id: key_id.clone(),
710 public_key_pem,
711 },
712 )?;
713 Ok(TrustRootAddOutcome {
714 added_key_id: key_id,
715 trusted_key_count: trust.keys.len(),
716 })
717 })
718 }
719
720 pub fn remove_trusted_key(
730 &self,
731 env_id: &EnvId,
732 key_id: String,
733 _idempotency_key: IdempotencyKey,
734 ) -> Result<TrustRootRemoveOutcome, StoreError> {
735 let env_dir = self.env_dir(env_id)?;
736 self.transact(env_id, |_locked| {
737 let pre = store_trust_root::load(&env_dir)?;
738 let removed_public_key_pem = pre
739 .keys
740 .iter()
741 .find(|k| k.key_id.eq_ignore_ascii_case(&key_id))
742 .map(|k| k.public_key_pem.clone());
743 let trust = store_trust_root::remove_trusted_key(&env_dir, &key_id)?;
744 Ok(TrustRootRemoveOutcome {
745 removed_key_id: key_id,
746 removed_public_key_pem,
747 trusted_key_count: trust.keys.len(),
748 })
749 })
750 }
751
752 pub fn add_extension_binding(
766 &self,
767 env_id: &EnvId,
768 binding: ExtensionBinding,
769 _idempotency_key: IdempotencyKey,
770 ) -> Result<ExtensionBinding, StoreError> {
771 self.transact(env_id, |locked| {
772 let mut env = locked.load()?;
773 let added =
774 engine::add_extension_binding(&mut env, binding).map_err(map_binding_err)?;
775 locked.save(&env)?;
776 Ok(added)
777 })
778 }
779
780 pub fn update_extension_binding(
790 &self,
791 env_id: &EnvId,
792 key: ExtensionKey,
793 binding: ExtensionBinding,
794 _idempotency_key: IdempotencyKey,
795 ) -> Result<(ExtensionBinding, u64), StoreError> {
796 self.transact(env_id, |locked| {
797 let mut env = locked.load()?;
798 let (updated, new_generation) =
799 engine::update_extension_binding(&mut env, &key, binding)
800 .map_err(map_binding_err)?;
801 locked.save(&env)?;
802 Ok((updated, new_generation))
803 })
804 }
805
806 pub fn remove_extension_binding(
812 &self,
813 env_id: &EnvId,
814 key: ExtensionKey,
815 _idempotency_key: IdempotencyKey,
816 ) -> Result<(ExtensionBinding, u64), StoreError> {
817 self.transact(env_id, |locked| {
818 let mut env = locked.load()?;
819 let (removed, generation) =
820 engine::remove_extension_binding(&mut env, &key).map_err(map_binding_err)?;
821 locked.save(&env)?;
822 Ok((removed, generation))
823 })
824 }
825
826 pub fn rollback_extension_binding(
833 &self,
834 env_id: &EnvId,
835 key: ExtensionKey,
836 _idempotency_key: IdempotencyKey,
837 ) -> Result<(ExtensionBinding, u64), StoreError> {
838 self.transact(env_id, |locked| {
839 let mut env = locked.load()?;
840 let (restored, new_generation) =
841 engine::rollback_extension_binding(&mut env, &key).map_err(map_binding_err)?;
842 locked.save(&env)?;
843 Ok((restored, new_generation))
844 })
845 }
846
847 pub fn set_traffic_split(
868 &self,
869 env_id: &EnvId,
870 payload: SetTrafficSplitPayload,
871 idempotency_key: IdempotencyKey,
872 ) -> Result<ApplyTrafficSplitOutcome, StoreError> {
873 self.transact(env_id, |locked| {
874 let mut env = locked.load()?;
875 let transition =
876 engine::set_traffic_split(&mut env, payload, &idempotency_key, Utc::now())
877 .map_err(map_traffic_err)?;
878 if transition.mutated() {
879 locked.save(&env)?;
880 locked
885 .refresh_runtime_config(&env)
886 .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
887 } else {
888 locked.refresh_runtime_config(&env)?;
892 }
893 Ok(ApplyTrafficSplitOutcome {
894 split: transition.split,
895 previous_generation: transition.previous_generation,
896 new_generation: transition.new_generation,
897 environment: env,
898 })
899 })
900 }
901
902 pub fn rollback_traffic_split(
914 &self,
915 env_id: &EnvId,
916 deployment_id: DeploymentId,
917 _idempotency_key: IdempotencyKey,
918 ) -> Result<RollbackTrafficSplitOutcome, StoreError> {
919 self.transact(env_id, |locked| {
920 let mut env = locked.load()?;
921 let transition = engine::rollback_traffic_split(&mut env, deployment_id, Utc::now())
922 .map_err(map_traffic_err)?;
923 locked.save(&env)?;
924 locked
926 .refresh_runtime_config(&env)
927 .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
928 Ok(RollbackTrafficSplitOutcome {
929 restored: transition.restored,
930 previous_generation: transition.previous_generation,
931 new_generation: transition.new_generation,
932 environment: env,
933 })
934 })
935 }
936
937 pub fn add_messaging_endpoint(
959 &self,
960 env_id: &EnvId,
961 payload: AddMessagingEndpointPayload,
962 idempotency_key: IdempotencyKey,
963 ) -> Result<MessagingEndpoint, StoreError> {
964 self.transact(env_id, |locked| {
965 let mut env = locked.load()?;
966 let eid = MessagingEndpointId::new();
967 let (owner, custodial) = webhook_provision_ctx(&env);
968 let applied = engine::add_messaging_endpoint(
969 &mut env,
970 payload,
971 eid,
972 &idempotency_key,
973 Utc::now(),
974 |existing| {
975 self.provision_webhook_secret_sink(
976 env_id,
977 &eid,
978 owner.as_deref(),
979 custodial,
980 existing,
981 )
982 },
983 )
984 .map_err(map_messaging_err)?;
985 self.finish_messaging_mutation(locked, &env, applied)
986 })
987 }
988
989 pub fn link_messaging_bundle(
994 &self,
995 env_id: &EnvId,
996 endpoint_id: MessagingEndpointId,
997 bundle_id: BundleId,
998 updated_by: String,
999 idempotency_key: IdempotencyKey,
1000 ) -> Result<MessagingEndpoint, StoreError> {
1001 self.transact(env_id, |locked| {
1002 let mut env = locked.load()?;
1003 let applied = engine::link_messaging_bundle(
1004 &mut env,
1005 endpoint_id,
1006 bundle_id,
1007 &updated_by,
1008 &idempotency_key,
1009 Utc::now(),
1010 )
1011 .map_err(map_messaging_err)?;
1012 self.finish_messaging_mutation(locked, &env, applied)
1013 })
1014 }
1015
1016 pub fn unlink_messaging_bundle(
1021 &self,
1022 env_id: &EnvId,
1023 endpoint_id: MessagingEndpointId,
1024 bundle_id: BundleId,
1025 updated_by: String,
1026 idempotency_key: IdempotencyKey,
1027 ) -> Result<MessagingEndpoint, StoreError> {
1028 self.transact(env_id, |locked| {
1029 let mut env = locked.load()?;
1030 let applied = engine::unlink_messaging_bundle(
1031 &mut env,
1032 endpoint_id,
1033 bundle_id,
1034 &updated_by,
1035 &idempotency_key,
1036 Utc::now(),
1037 )
1038 .map_err(map_messaging_err)?;
1039 self.finish_messaging_mutation(locked, &env, applied)
1040 })
1041 }
1042
1043 pub fn set_messaging_welcome_flow(
1049 &self,
1050 env_id: &EnvId,
1051 payload: SetMessagingWelcomeFlowPayload,
1052 idempotency_key: IdempotencyKey,
1053 ) -> Result<MessagingEndpoint, StoreError> {
1054 self.transact(env_id, |locked| {
1055 let mut env = locked.load()?;
1056 let applied =
1057 engine::set_messaging_welcome_flow(&mut env, payload, &idempotency_key, Utc::now())
1058 .map_err(map_messaging_err)?;
1059 self.finish_messaging_mutation(locked, &env, applied)
1060 })
1061 }
1062
1063 pub fn remove_messaging_endpoint(
1067 &self,
1068 env_id: &EnvId,
1069 endpoint_id: MessagingEndpointId,
1070 ) -> Result<MessagingEndpointId, StoreError> {
1071 self.transact(env_id, |locked| {
1072 let mut env = locked.load()?;
1073 if engine::remove_messaging_endpoint(&mut env, endpoint_id) {
1074 locked.save(&env)?;
1075 }
1076 locked
1079 .refresh_messaging_projection(&env)
1080 .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
1081 Ok(endpoint_id)
1082 })
1083 }
1084
1085 pub fn rotate_messaging_webhook_secret(
1091 &self,
1092 env_id: &EnvId,
1093 endpoint_id: MessagingEndpointId,
1094 updated_by: String,
1095 idempotency_key: IdempotencyKey,
1096 ) -> Result<MessagingEndpoint, StoreError> {
1097 self.transact(env_id, |locked| {
1098 let mut env = locked.load()?;
1099 let (owner, custodial) = webhook_provision_ctx(&env);
1100 if !custodial {
1105 return Err(StoreError::Conflict(format!(
1106 "cannot rotate a webhook secret on a non-dev-store secrets backend from the \
1107 control plane (env `{}`); rotate the value in the secrets backend and \
1108 re-seed it (e.g. via `op secrets put`)",
1109 env_id.as_str()
1110 )));
1111 }
1112 let applied = engine::rotate_messaging_webhook_secret(
1113 &mut env,
1114 endpoint_id,
1115 &updated_by,
1116 &idempotency_key,
1117 Utc::now(),
1118 None,
1121 |existing| {
1122 self.provision_webhook_secret_sink(
1123 env_id,
1124 &endpoint_id,
1125 owner.as_deref(),
1126 custodial,
1127 existing,
1128 )
1129 },
1130 )
1131 .map_err(map_messaging_err)?;
1132 self.finish_messaging_mutation(locked, &env, applied)
1133 })
1134 }
1135
1136 fn provision_webhook_secret_sink(
1145 &self,
1146 env_id: &EnvId,
1147 endpoint_id: &MessagingEndpointId,
1148 owner: Option<&str>,
1149 custodial: bool,
1150 existing_ref: Option<&SecretRef>,
1151 ) -> Result<SecretRef, engine::MessagingError> {
1152 crate::cli::messaging::provision_webhook_secret(
1153 self,
1154 env_id,
1155 endpoint_id,
1156 owner,
1157 custodial,
1158 existing_ref,
1159 )
1160 .map_err(|e| engine::MessagingError::SecretProvision(e.to_string()))
1161 }
1162
1163 fn finish_messaging_mutation(
1168 &self,
1169 locked: &super::store::Locked<'_>,
1170 env: &Environment,
1171 applied: engine::MessagingApplied,
1172 ) -> Result<MessagingEndpoint, StoreError> {
1173 if applied.mutated {
1174 locked.save(env)?;
1175 }
1176 let ep = env.messaging_endpoints[applied.index].clone();
1177 locked
1178 .refresh_messaging_projection(env)
1179 .map_err(|e| StoreError::CommittedAfterSave(Box::new(e)))?;
1180 Ok(ep)
1181 }
1182
1183 pub fn ensure_local_environment(
1207 &self,
1208 env_id: &EnvId,
1209 payload: EnsureLocalEnvironmentPayload,
1210 ) -> Result<(Environment, LocalEnvOutcome), StoreError> {
1211 self.transact(env_id, |locked| {
1212 match locked.load() {
1213 Ok(mut existing) => {
1214 if payload.public_base_url.is_some() {
1217 return Err(StoreError::InvalidArgument(format!(
1218 "env `{}` already exists; use `op env set-public-url <env_id> <URL>` \
1219 to overwrite the persisted public URL",
1220 locked.env_id()
1221 )));
1222 }
1223 let added = fill_missing_default_bindings(&mut existing)?;
1224 if added.is_empty() {
1225 return Ok((existing, LocalEnvOutcome::AlreadyExists));
1226 }
1227 locked.save(&existing)?;
1228 Ok((existing, LocalEnvOutcome::Healed { added_slots: added }))
1229 }
1230 Err(StoreError::NotFound(_)) => {
1231 let packs = crate::defaults::local_pack_bindings().map_err(|e| {
1232 StoreError::InvalidArgument(format!("default pack binding parse: {e}"))
1233 })?;
1234 let env = Environment {
1235 schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
1236 environment_id: locked.env_id().clone(),
1237 name: env_id.as_str().to_string(),
1238 host_config: EnvironmentHostConfig {
1239 env_id: locked.env_id().clone(),
1240 region: None,
1241 tenant_org_id: None,
1242 listen_addr: Some(greentic_deploy_spec::DEFAULT_LISTEN_ADDR),
1243 public_base_url: payload.public_base_url.clone(),
1244 gui_enabled: None,
1245 },
1246 packs,
1247 credentials_ref: None,
1248 bundles: Vec::new(),
1249 revisions: Vec::new(),
1250 traffic_splits: Vec::new(),
1251 messaging_endpoints: Vec::new(),
1252 extensions: Vec::new(),
1253 revocation: Default::default(),
1254 retention: Default::default(),
1255 health: Default::default(),
1256 };
1257 locked.save(&env)?;
1258 Ok((env, LocalEnvOutcome::Created))
1259 }
1260 Err(e) => Err(e),
1261 }
1262 })
1263 }
1264}
1265
1266fn seed_op_key(
1278 env_dir: &Path,
1279 op_key: crate::operator_key::OperatorKey,
1280) -> Result<TrustRootSeed, StoreError> {
1281 let trust = store_trust_root::add_trusted_key(
1282 env_dir,
1283 TrustedKey {
1284 key_id: op_key.key_id.clone(),
1285 public_key_pem: op_key.public_pem.clone(),
1286 },
1287 )?;
1288 Ok(TrustRootSeed {
1289 key_id: op_key.key_id,
1290 public_key_pem: op_key.public_pem,
1291 trusted_key_count: trust.keys.len(),
1292 })
1293}
1294
1295impl EnvironmentMutations for LocalFsStore {
1304 fn create_environment(
1306 &self,
1307 env_id: &EnvId,
1308 name: String,
1309 host_config: EnvironmentHostConfig,
1310 ) -> Result<Environment, StoreError> {
1311 self.create_environment(env_id, name, host_config)
1312 }
1313
1314 fn update_environment(
1316 &self,
1317 env_id: &EnvId,
1318 patch: UpdateEnvironmentPayload,
1319 ) -> Result<Environment, StoreError> {
1320 self.update_environment(env_id, patch)
1321 }
1322
1323 fn load_environment(&self, env_id: &EnvId) -> Result<Environment, StoreError> {
1327 EnvironmentStore::load(self, env_id)
1328 }
1329
1330 fn trust_root_is_seeded(&self, env_id: &EnvId) -> Result<bool, StoreError> {
1334 let env_dir = self.env_dir(env_id)?;
1335 Ok(trust_root_path(&env_dir).exists())
1336 }
1337
1338 fn migrate_merge_bindings(
1340 &self,
1341 target_env_id: &EnvId,
1342 payload: MigrateMergePayload,
1343 ) -> Result<(Vec<String>, Vec<String>), StoreError> {
1344 self.migrate_merge_bindings(target_env_id, payload)
1345 }
1346
1347 fn stage_revision(
1349 &self,
1350 env_id: &EnvId,
1351 payload: StageRevisionPayload,
1352 idempotency_key: IdempotencyKey,
1353 ) -> Result<Revision, StoreError> {
1354 self.stage_revision(env_id, payload, idempotency_key)
1355 }
1356
1357 fn warm_revision(
1359 &self,
1360 env_id: &EnvId,
1361 payload: WarmRevisionPayload,
1362 idempotency_key: IdempotencyKey,
1363 ) -> Result<RevisionTransitionOutcome, StoreError> {
1364 self.warm_revision(env_id, payload, idempotency_key)
1365 }
1366
1367 fn drain_revision(
1369 &self,
1370 env_id: &EnvId,
1371 revision_id: RevisionId,
1372 idempotency_key: IdempotencyKey,
1373 ) -> Result<RevisionTransitionOutcome, StoreError> {
1374 self.drain_revision(env_id, revision_id, idempotency_key)
1375 }
1376
1377 fn archive_revision(
1379 &self,
1380 env_id: &EnvId,
1381 revision_id: RevisionId,
1382 idempotency_key: IdempotencyKey,
1383 ) -> Result<RevisionTransitionOutcome, StoreError> {
1384 self.archive_revision(env_id, revision_id, idempotency_key)
1385 }
1386
1387 fn add_bundle(
1389 &self,
1390 env_id: &EnvId,
1391 payload: AddBundlePayload,
1392 idempotency_key: IdempotencyKey,
1393 ) -> Result<BundleDeployment, StoreError> {
1394 self.add_bundle(env_id, payload, idempotency_key)
1395 }
1396
1397 fn update_bundle(
1399 &self,
1400 env_id: &EnvId,
1401 payload: UpdateBundlePayload,
1402 idempotency_key: IdempotencyKey,
1403 ) -> Result<BundleDeployment, StoreError> {
1404 self.update_bundle(env_id, payload, idempotency_key)
1405 }
1406
1407 fn remove_bundle(
1409 &self,
1410 env_id: &EnvId,
1411 deployment_id: DeploymentId,
1412 idempotency_key: IdempotencyKey,
1413 ) -> Result<RemoveBundleOutcome, StoreError> {
1414 self.remove_bundle(env_id, deployment_id, idempotency_key)
1415 }
1416
1417 fn add_pack_binding(
1419 &self,
1420 env_id: &EnvId,
1421 binding: EnvPackBinding,
1422 idempotency_key: IdempotencyKey,
1423 ) -> Result<EnvPackBinding, StoreError> {
1424 self.add_pack_binding(env_id, binding, idempotency_key)
1425 }
1426
1427 fn update_pack_binding(
1429 &self,
1430 env_id: &EnvId,
1431 slot: CapabilitySlot,
1432 binding: EnvPackBinding,
1433 idempotency_key: IdempotencyKey,
1434 ) -> Result<(EnvPackBinding, u64), StoreError> {
1435 self.update_pack_binding(env_id, slot, binding, idempotency_key)
1436 }
1437
1438 fn remove_pack_binding(
1440 &self,
1441 env_id: &EnvId,
1442 slot: CapabilitySlot,
1443 idempotency_key: IdempotencyKey,
1444 ) -> Result<(EnvPackBinding, u64), StoreError> {
1445 self.remove_pack_binding(env_id, slot, idempotency_key)
1446 }
1447
1448 fn rollback_pack_binding(
1450 &self,
1451 env_id: &EnvId,
1452 slot: CapabilitySlot,
1453 idempotency_key: IdempotencyKey,
1454 ) -> Result<(EnvPackBinding, u64), StoreError> {
1455 self.rollback_pack_binding(env_id, slot, idempotency_key)
1456 }
1457
1458 fn add_extension_binding(
1460 &self,
1461 env_id: &EnvId,
1462 binding: ExtensionBinding,
1463 idempotency_key: IdempotencyKey,
1464 ) -> Result<ExtensionBinding, StoreError> {
1465 self.add_extension_binding(env_id, binding, idempotency_key)
1466 }
1467
1468 fn update_extension_binding(
1470 &self,
1471 env_id: &EnvId,
1472 key: ExtensionKey,
1473 binding: ExtensionBinding,
1474 idempotency_key: IdempotencyKey,
1475 ) -> Result<(ExtensionBinding, u64), StoreError> {
1476 self.update_extension_binding(env_id, key, binding, idempotency_key)
1477 }
1478
1479 fn remove_extension_binding(
1481 &self,
1482 env_id: &EnvId,
1483 key: ExtensionKey,
1484 idempotency_key: IdempotencyKey,
1485 ) -> Result<(ExtensionBinding, u64), StoreError> {
1486 self.remove_extension_binding(env_id, key, idempotency_key)
1487 }
1488
1489 fn rollback_extension_binding(
1491 &self,
1492 env_id: &EnvId,
1493 key: ExtensionKey,
1494 idempotency_key: IdempotencyKey,
1495 ) -> Result<(ExtensionBinding, u64), StoreError> {
1496 self.rollback_extension_binding(env_id, key, idempotency_key)
1497 }
1498
1499 fn set_traffic_split(
1501 &self,
1502 env_id: &EnvId,
1503 payload: SetTrafficSplitPayload,
1504 idempotency_key: IdempotencyKey,
1505 ) -> Result<ApplyTrafficSplitOutcome, StoreError> {
1506 self.set_traffic_split(env_id, payload, idempotency_key)
1507 }
1508
1509 fn rollback_traffic_split(
1511 &self,
1512 env_id: &EnvId,
1513 deployment_id: DeploymentId,
1514 idempotency_key: IdempotencyKey,
1515 ) -> Result<RollbackTrafficSplitOutcome, StoreError> {
1516 self.rollback_traffic_split(env_id, deployment_id, idempotency_key)
1517 }
1518
1519 fn add_messaging_endpoint(
1521 &self,
1522 env_id: &EnvId,
1523 payload: AddMessagingEndpointPayload,
1524 idempotency_key: IdempotencyKey,
1525 ) -> Result<MessagingEndpoint, StoreError> {
1526 self.add_messaging_endpoint(env_id, payload, idempotency_key)
1527 }
1528
1529 fn link_messaging_bundle(
1531 &self,
1532 env_id: &EnvId,
1533 endpoint_id: MessagingEndpointId,
1534 bundle_id: BundleId,
1535 updated_by: String,
1536 idempotency_key: IdempotencyKey,
1537 ) -> Result<MessagingEndpoint, StoreError> {
1538 self.link_messaging_bundle(env_id, endpoint_id, bundle_id, updated_by, idempotency_key)
1539 }
1540
1541 fn unlink_messaging_bundle(
1543 &self,
1544 env_id: &EnvId,
1545 endpoint_id: MessagingEndpointId,
1546 bundle_id: BundleId,
1547 updated_by: String,
1548 idempotency_key: IdempotencyKey,
1549 ) -> Result<MessagingEndpoint, StoreError> {
1550 self.unlink_messaging_bundle(env_id, endpoint_id, bundle_id, updated_by, idempotency_key)
1551 }
1552
1553 fn set_messaging_welcome_flow(
1555 &self,
1556 env_id: &EnvId,
1557 payload: SetMessagingWelcomeFlowPayload,
1558 idempotency_key: IdempotencyKey,
1559 ) -> Result<MessagingEndpoint, StoreError> {
1560 self.set_messaging_welcome_flow(env_id, payload, idempotency_key)
1561 }
1562
1563 fn remove_messaging_endpoint(
1565 &self,
1566 env_id: &EnvId,
1567 endpoint_id: MessagingEndpointId,
1568 ) -> Result<MessagingEndpointId, StoreError> {
1569 self.remove_messaging_endpoint(env_id, endpoint_id)
1570 }
1571
1572 fn rotate_messaging_webhook_secret(
1574 &self,
1575 env_id: &EnvId,
1576 endpoint_id: MessagingEndpointId,
1577 updated_by: String,
1578 idempotency_key: IdempotencyKey,
1579 ) -> Result<MessagingEndpoint, StoreError> {
1580 self.rotate_messaging_webhook_secret(env_id, endpoint_id, updated_by, idempotency_key)
1581 }
1582
1583 fn bootstrap_trust_root(&self, env_id: &EnvId) -> Result<TrustRootSeed, StoreError> {
1585 self.bootstrap_trust_root(env_id)
1586 }
1587
1588 fn seed_trust_root_if_absent(
1590 &self,
1591 env_id: &EnvId,
1592 ) -> Result<Option<TrustRootSeed>, StoreError> {
1593 self.seed_trust_root_if_absent(env_id)
1594 }
1595
1596 fn add_trusted_key(
1598 &self,
1599 env_id: &EnvId,
1600 key_id: String,
1601 public_key_pem: String,
1602 idempotency_key: IdempotencyKey,
1603 ) -> Result<TrustRootAddOutcome, StoreError> {
1604 self.add_trusted_key(env_id, key_id, public_key_pem, idempotency_key)
1605 }
1606
1607 fn remove_trusted_key(
1609 &self,
1610 env_id: &EnvId,
1611 key_id: String,
1612 idempotency_key: IdempotencyKey,
1613 ) -> Result<TrustRootRemoveOutcome, StoreError> {
1614 self.remove_trusted_key(env_id, key_id, idempotency_key)
1615 }
1616}
1617
1618#[cfg(test)]
1619mod warm_revision_tests {
1620 use super::*;
1626 use crate::environment::lifecycle::{HealthCheckId, HealthGateFailure, LifecycleError};
1627 use crate::environment::store::EnvironmentStore;
1628 use chrono::{TimeZone, Utc};
1629 use greentic_deploy_spec::{
1630 BundleDeployment, BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId,
1631 Environment, EnvironmentHostConfig, PartyId, RevenueShareEntry, Revision, RevisionId,
1632 RevisionLifecycle, RouteBinding, SchemaVersion, TenantSelector,
1633 };
1634 use std::collections::BTreeMap;
1635 use std::path::PathBuf;
1636 use tempfile::tempdir;
1637
1638 const ENV_ID: &str = "local";
1639
1640 fn env_id() -> EnvId {
1641 EnvId::try_from(ENV_ID).unwrap()
1642 }
1643
1644 fn seed_one_staged() -> (LocalFsStore, EnvId, RevisionId) {
1645 let dir = tempdir().unwrap();
1646 let store = LocalFsStore::new(dir.path().to_path_buf());
1647 let did = DeploymentId::new();
1648 let rid = RevisionId::new();
1649 let env = Environment {
1650 schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
1651 environment_id: env_id(),
1652 name: ENV_ID.to_string(),
1653 host_config: EnvironmentHostConfig {
1654 env_id: env_id(),
1655 region: None,
1656 tenant_org_id: None,
1657 listen_addr: None,
1658 public_base_url: None,
1659 gui_enabled: None,
1660 },
1661 packs: Vec::new(),
1662 credentials_ref: None,
1663 bundles: vec![BundleDeployment {
1664 schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
1665 deployment_id: did,
1666 env_id: env_id(),
1667 bundle_id: BundleId::new("fast2flow"),
1668 customer_id: CustomerId::new("local-dev"),
1669 status: BundleDeploymentStatus::Active,
1670 current_revisions: vec![rid],
1671 route_binding: RouteBinding {
1672 hosts: vec!["fast2flow.local".to_string()],
1673 path_prefixes: Vec::new(),
1674 tenant_selector: TenantSelector {
1675 tenant: "default".to_string(),
1676 team: "default".to_string(),
1677 },
1678 },
1679 revenue_share: vec![RevenueShareEntry {
1680 party_id: PartyId::new("greentic"),
1681 basis_points: 10_000,
1682 }],
1683 revenue_policy_ref: PathBuf::from("revenue.json"),
1684 usage: None,
1685 created_at: Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap(),
1686 authorization_ref: PathBuf::from("auth.json"),
1687 config_overrides: BTreeMap::new(),
1688 }],
1689 revisions: vec![Revision {
1690 schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
1691 revision_id: rid,
1692 env_id: env_id(),
1693 bundle_id: BundleId::new("fast2flow"),
1694 deployment_id: did,
1695 sequence: 1,
1696 created_at: Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap(),
1697 bundle_digest: "sha256:00".to_string(),
1698 bundle_source_uri: None,
1699 pack_list: Vec::new(),
1700 pack_list_lock_ref: PathBuf::new(),
1701 pack_config_refs: Vec::new(),
1702 config_digest: "sha256:00".to_string(),
1703 signature_sidecar_ref: PathBuf::from("rev.sig"),
1704 lifecycle: RevisionLifecycle::Staged,
1705 staged_at: Some(Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap()),
1706 warmed_at: None,
1707 drain_seconds: 30,
1708 abort_metrics: Vec::new(),
1709 }],
1710 traffic_splits: Vec::new(),
1711 messaging_endpoints: Vec::new(),
1712 extensions: Vec::new(),
1713 revocation: Default::default(),
1714 retention: Default::default(),
1715 health: Default::default(),
1716 };
1717 store.save(&env).unwrap();
1718 let _ = dir.keep();
1721 (store, env_id(), rid)
1722 }
1723
1724 fn idem() -> IdempotencyKey {
1725 IdempotencyKey::new(ulid::Ulid::new().to_string()).unwrap()
1726 }
1727
1728 #[test]
1729 fn warm_revision_with_passing_gate_lands_ready_and_stamps_warmed_at() {
1730 let (store, env_id, rid) = seed_one_staged();
1731 let outcome = store
1732 .warm_revision(
1733 &env_id,
1734 WarmRevisionPayload {
1735 revision_id: rid,
1736 health_gate: Ok(()),
1737 expected_lifecycle: RevisionLifecycle::Staged,
1738 },
1739 idem(),
1740 )
1741 .unwrap();
1742 assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Ready);
1743 assert!(outcome.revision.warmed_at.is_some());
1744 assert_eq!(outcome.starting_lifecycle, RevisionLifecycle::Staged);
1745 let env = store.load(&env_id).unwrap();
1747 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1748 }
1749
1750 #[test]
1751 fn warm_revision_with_failing_gate_persists_failed_and_surfaces_health_gate_error() {
1752 let (store, env_id, rid) = seed_one_staged();
1753 let err = store
1754 .warm_revision(
1755 &env_id,
1756 WarmRevisionPayload {
1757 revision_id: rid,
1758 health_gate: Err(HealthGateFailure {
1759 failed_checks: vec![HealthCheckId::RouteTable],
1760 message: "missing routes".to_string(),
1761 }),
1762 expected_lifecycle: RevisionLifecycle::Staged,
1763 },
1764 idem(),
1765 )
1766 .unwrap_err();
1767 match err {
1768 StoreError::Lifecycle(inner) => match *inner {
1769 LifecycleError::HealthGateFailed {
1770 revision_id,
1771 failed_checks,
1772 ..
1773 } => {
1774 assert_eq!(revision_id, rid);
1775 assert_eq!(failed_checks, vec![HealthCheckId::RouteTable]);
1776 }
1777 other => panic!("expected HealthGateFailed, got {other:?}"),
1778 },
1779 other => panic!("expected StoreError::Lifecycle, got {other:?}"),
1780 }
1781 let env = store.load(&env_id).unwrap();
1783 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
1784 }
1785
1786 #[test]
1787 fn drain_revision_advances_ready_to_draining() {
1788 let (store, env_id, rid) = seed_one_staged();
1789 store
1791 .warm_revision(
1792 &env_id,
1793 WarmRevisionPayload {
1794 revision_id: rid,
1795 health_gate: Ok(()),
1796 expected_lifecycle: RevisionLifecycle::Staged,
1797 },
1798 idem(),
1799 )
1800 .unwrap();
1801 let outcome = store.drain_revision(&env_id, rid, idem()).unwrap();
1802 assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Draining);
1803 assert_eq!(outcome.starting_lifecycle, RevisionLifecycle::Ready);
1804 }
1805
1806 #[test]
1807 fn archive_revision_walks_draining_through_inactive_to_archived() {
1808 let (store, env_id, rid) = seed_one_staged();
1809 store
1810 .warm_revision(
1811 &env_id,
1812 WarmRevisionPayload {
1813 revision_id: rid,
1814 health_gate: Ok(()),
1815 expected_lifecycle: RevisionLifecycle::Staged,
1816 },
1817 idem(),
1818 )
1819 .unwrap();
1820 store.drain_revision(&env_id, rid, idem()).unwrap();
1821 let outcome = store.archive_revision(&env_id, rid, idem()).unwrap();
1822 assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Archived);
1823 assert_eq!(outcome.starting_lifecycle, RevisionLifecycle::Draining);
1828 }
1829
1830 #[test]
1835 fn warm_with_concurrent_lifecycle_change_rejects() {
1836 let (store, env_id, rid) = seed_one_staged();
1837 let err = store
1841 .warm_revision(
1842 &env_id,
1843 WarmRevisionPayload {
1844 revision_id: rid,
1845 health_gate: Ok(()),
1846 expected_lifecycle: RevisionLifecycle::Ready,
1847 },
1848 idem(),
1849 )
1850 .unwrap_err();
1851 match err {
1852 StoreError::Lifecycle(inner) => match *inner {
1853 LifecycleError::Conflict {
1854 revision_id: conflict_rid,
1855 actual,
1856 expected_starts,
1857 } => {
1858 assert_eq!(conflict_rid, rid);
1859 assert_eq!(actual, RevisionLifecycle::Staged);
1860 assert_eq!(expected_starts, vec![RevisionLifecycle::Ready]);
1861 }
1862 other => panic!("expected LifecycleError::Conflict, got {other:?}"),
1863 },
1864 other => panic!("expected StoreError::Lifecycle, got {other:?}"),
1865 }
1866 let env = store.load(&env_id).unwrap();
1868 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Staged);
1869 }
1870
1871 #[test]
1876 fn warm_idempotent_retry_skips_precondition() {
1877 let (store, env_id, rid) = seed_one_staged();
1878 store
1880 .warm_revision(
1881 &env_id,
1882 WarmRevisionPayload {
1883 revision_id: rid,
1884 health_gate: Ok(()),
1885 expected_lifecycle: RevisionLifecycle::Staged,
1886 },
1887 idem(),
1888 )
1889 .unwrap();
1890 let outcome = store
1894 .warm_revision(
1895 &env_id,
1896 WarmRevisionPayload {
1897 revision_id: rid,
1898 health_gate: Ok(()),
1899 expected_lifecycle: RevisionLifecycle::Staged,
1900 },
1901 idem(),
1902 )
1903 .unwrap();
1904 assert_eq!(outcome.revision.lifecycle, RevisionLifecycle::Ready);
1905 }
1906}
1907
1908#[cfg(test)]
1909mod bootstrap_typed_verb_tests {
1910 use super::*;
1916 use crate::defaults::{
1917 LOCAL_DEPLOYER_PACK, LOCAL_ENV_ID, LOCAL_SECRETS_PACK, LOCAL_SESSIONS_PACK,
1918 LOCAL_STATE_PACK, LOCAL_TELEMETRY_PACK,
1919 };
1920 use crate::environment::bootstrap::{EnsureLocalEnvironmentPayload, LocalEnvOutcome};
1921 use crate::environment::store::EnvironmentStore;
1922 use greentic_deploy_spec::{CapabilitySlot, EnvId, EnvPackBinding, PackDescriptor, PackId};
1923 use tempfile::TempDir;
1924
1925 fn store() -> (TempDir, LocalFsStore) {
1926 let tmp = TempDir::new().expect("tempdir");
1927 let s = LocalFsStore::new(tmp.path().to_path_buf());
1928 (tmp, s)
1929 }
1930
1931 fn env_id() -> EnvId {
1932 EnvId::try_from(LOCAL_ENV_ID).unwrap()
1933 }
1934
1935 fn payload(public_base_url: Option<&str>) -> EnsureLocalEnvironmentPayload {
1936 EnsureLocalEnvironmentPayload {
1937 public_base_url: public_base_url.map(ToString::to_string),
1938 }
1939 }
1940
1941 #[test]
1942 fn creates_env_when_missing() {
1943 let (_tmp, store) = store();
1944 let (env, outcome) = store
1945 .ensure_local_environment(&env_id(), payload(None))
1946 .expect("create");
1947 assert_eq!(outcome, LocalEnvOutcome::Created);
1948 assert_eq!(env.environment_id.as_str(), LOCAL_ENV_ID);
1949 assert_eq!(env.name, LOCAL_ENV_ID);
1950 assert_eq!(env.packs.len(), 5);
1951 env.validate().expect("spec-valid");
1952 }
1953
1954 #[test]
1955 fn creates_env_with_public_base_url() {
1956 let (_tmp, store) = store();
1957 let (env, outcome) = store
1958 .ensure_local_environment(&env_id(), payload(Some("https://example.com")))
1959 .expect("create with url");
1960 assert_eq!(outcome, LocalEnvOutcome::Created);
1961 assert_eq!(
1962 env.host_config.public_base_url.as_deref(),
1963 Some("https://example.com")
1964 );
1965 }
1966
1967 #[test]
1968 fn returns_already_exists_on_second_call() {
1969 let (_tmp, store) = store();
1970 let (first, _) = store
1971 .ensure_local_environment(&env_id(), payload(None))
1972 .expect("first");
1973 let (second, outcome) = store
1974 .ensure_local_environment(&env_id(), payload(None))
1975 .expect("second");
1976 assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
1977 assert_eq!(first, second);
1978 }
1979
1980 #[test]
1981 fn rejects_public_base_url_when_env_exists() {
1982 let (_tmp, store) = store();
1983 store
1984 .ensure_local_environment(&env_id(), payload(None))
1985 .expect("first");
1986 let err = store
1987 .ensure_local_environment(&env_id(), payload(Some("https://example.com")))
1988 .unwrap_err();
1989 assert!(
1990 matches!(err, StoreError::InvalidArgument(ref msg) if msg.contains("already exists")),
1991 "expected InvalidArgument, got {err:?}"
1992 );
1993 }
1994
1995 fn seed_empty_local_env(store: &LocalFsStore) -> greentic_deploy_spec::Environment {
1997 let eid = env_id();
1998 let env = greentic_deploy_spec::Environment {
1999 schema: greentic_deploy_spec::SchemaVersion::new(
2000 greentic_deploy_spec::SchemaVersion::ENVIRONMENT_V1,
2001 ),
2002 environment_id: eid.clone(),
2003 name: LOCAL_ENV_ID.to_string(),
2004 host_config: greentic_deploy_spec::EnvironmentHostConfig {
2005 env_id: eid,
2006 region: None,
2007 tenant_org_id: None,
2008 listen_addr: None,
2009 public_base_url: None,
2010 gui_enabled: None,
2011 },
2012 packs: Vec::new(),
2013 credentials_ref: None,
2014 bundles: Vec::new(),
2015 revisions: Vec::new(),
2016 traffic_splits: Vec::new(),
2017 messaging_endpoints: Vec::new(),
2018 extensions: Vec::new(),
2019 revocation: Default::default(),
2020 retention: Default::default(),
2021 health: Default::default(),
2022 };
2023 store.save(&env).expect("seed");
2024 env
2025 }
2026
2027 fn custom_binding(slot: CapabilitySlot, descriptor: &str) -> EnvPackBinding {
2028 EnvPackBinding {
2029 slot,
2030 kind: PackDescriptor::try_new(descriptor).expect("valid"),
2031 pack_ref: PackId::new(descriptor),
2032 answers_ref: None,
2033 generation: 0,
2034 previous_binding_ref: None,
2035 }
2036 }
2037
2038 #[test]
2039 fn webhook_provision_ctx_trims_and_blanks_owner() {
2040 let (_tmp, store) = store();
2041 let mut env = seed_empty_local_env(&store);
2042 let (owner, custodial) = webhook_provision_ctx(&env);
2044 assert_eq!(owner, None);
2045 assert!(custodial);
2046 env.host_config.tenant_org_id = Some(" tenant-default ".to_string());
2048 assert_eq!(
2049 webhook_provision_ctx(&env).0.as_deref(),
2050 Some("tenant-default")
2051 );
2052 env.host_config.tenant_org_id = Some(" ".to_string());
2054 assert_eq!(webhook_provision_ctx(&env).0, None);
2055 }
2056
2057 #[test]
2058 fn rotate_messaging_webhook_secret_rejects_non_dev_store_backend() {
2059 let (_tmp, store) = store();
2060 let mut env = seed_empty_local_env(&store);
2061 env.host_config.tenant_org_id = Some("tenant-default".to_string());
2063 env.packs.push(custom_binding(
2064 CapabilitySlot::Secrets,
2065 crate::defaults::VAULT_SECRETS_PACK,
2066 ));
2067 store.save(&env).unwrap();
2068
2069 let added = store
2071 .add_messaging_endpoint(
2072 &env_id(),
2073 AddMessagingEndpointPayload {
2074 provider_id: "tg".to_string(),
2075 provider_type: "telegram".to_string(),
2076 display_name: "t".to_string(),
2077 secret_refs: Vec::new(),
2078 webhook_secret_ref: None,
2079 updated_by: "tester".to_string(),
2080 },
2081 IdempotencyKey::new(ulid::Ulid::new().to_string()).unwrap(),
2082 )
2083 .unwrap();
2084 assert!(
2085 added
2086 .webhook_secret_ref
2087 .as_ref()
2088 .expect("telegram endpoint stamps a ref")
2089 .as_str()
2090 .starts_with("secret://local/tenant-default/_/"),
2091 "add must stamp the owner-tenant ref"
2092 );
2093
2094 let err = store
2096 .rotate_messaging_webhook_secret(
2097 &env_id(),
2098 added.endpoint_id,
2099 "tester".to_string(),
2100 IdempotencyKey::new(ulid::Ulid::new().to_string()).unwrap(),
2101 )
2102 .unwrap_err();
2103 assert!(
2104 matches!(err, StoreError::Conflict(ref m) if m.contains("non-dev-store")),
2105 "expected Conflict, got {err:?}"
2106 );
2107 }
2108
2109 #[test]
2110 fn heals_env_with_no_packs() {
2111 let (_tmp, store) = store();
2112 seed_empty_local_env(&store);
2113 let (env, outcome) = store
2114 .ensure_local_environment(&env_id(), payload(None))
2115 .expect("heal");
2116 match outcome {
2117 LocalEnvOutcome::Healed { added_slots } => {
2118 assert_eq!(
2119 added_slots,
2120 vec![
2121 CapabilitySlot::Deployer,
2122 CapabilitySlot::Secrets,
2123 CapabilitySlot::Telemetry,
2124 CapabilitySlot::Sessions,
2125 CapabilitySlot::State,
2126 ]
2127 );
2128 }
2129 other => panic!("expected Healed, got {other:?}"),
2130 }
2131 assert_eq!(env.packs.len(), 5);
2132 env.validate().expect("spec-valid after heal");
2133 let (_, outcome2) = store
2135 .ensure_local_environment(&env_id(), payload(None))
2136 .expect("second");
2137 assert_eq!(outcome2, LocalEnvOutcome::AlreadyExists);
2138 }
2139
2140 #[test]
2141 fn heals_env_with_partial_packs() {
2142 let (_tmp, store) = store();
2143 let mut env = seed_empty_local_env(&store);
2144 env.packs.push(custom_binding(
2145 CapabilitySlot::Deployer,
2146 LOCAL_DEPLOYER_PACK,
2147 ));
2148 store.save(&env).expect("partial save");
2149
2150 let (env, outcome) = store
2151 .ensure_local_environment(&env_id(), payload(None))
2152 .expect("heal");
2153 match outcome {
2154 LocalEnvOutcome::Healed { added_slots } => {
2155 assert_eq!(
2156 added_slots,
2157 vec![
2158 CapabilitySlot::Secrets,
2159 CapabilitySlot::Telemetry,
2160 CapabilitySlot::Sessions,
2161 CapabilitySlot::State,
2162 ]
2163 );
2164 }
2165 other => panic!("expected Healed, got {other:?}"),
2166 }
2167 assert_eq!(env.packs.len(), 5);
2168 }
2169
2170 #[test]
2171 fn heal_preserves_user_bound_non_default_descriptor() {
2172 let (_tmp, store) = store();
2173 let mut env = seed_empty_local_env(&store);
2174 let custom_secrets = "greentic.secrets.aws-secrets-manager@1.0.0";
2175 env.packs
2176 .push(custom_binding(CapabilitySlot::Secrets, custom_secrets));
2177 store.save(&env).expect("custom-secrets save");
2178
2179 let (env, outcome) = store
2180 .ensure_local_environment(&env_id(), payload(None))
2181 .expect("heal");
2182 match outcome {
2183 LocalEnvOutcome::Healed { added_slots } => {
2184 assert_eq!(
2185 added_slots,
2186 vec![
2187 CapabilitySlot::Deployer,
2188 CapabilitySlot::Telemetry,
2189 CapabilitySlot::Sessions,
2190 CapabilitySlot::State,
2191 ]
2192 );
2193 }
2194 other => panic!("expected Healed, got {other:?}"),
2195 }
2196 let secrets_desc = env
2197 .packs
2198 .iter()
2199 .find(|b| b.slot == CapabilitySlot::Secrets)
2200 .map(|b| b.kind.as_str())
2201 .expect("secrets slot");
2202 assert_eq!(secrets_desc, custom_secrets);
2203 }
2204
2205 #[test]
2206 fn default_bindings_cover_expected_descriptors() {
2207 let (_tmp, store) = store();
2208 let (env, _) = store
2209 .ensure_local_environment(&env_id(), payload(None))
2210 .expect("create");
2211 let by_slot: std::collections::BTreeMap<CapabilitySlot, &str> = env
2212 .packs
2213 .iter()
2214 .map(|b| (b.slot, b.kind.as_str()))
2215 .collect();
2216 assert_eq!(by_slot[&CapabilitySlot::Deployer], LOCAL_DEPLOYER_PACK);
2217 assert_eq!(by_slot[&CapabilitySlot::Secrets], LOCAL_SECRETS_PACK);
2218 assert_eq!(by_slot[&CapabilitySlot::Telemetry], LOCAL_TELEMETRY_PACK);
2219 assert_eq!(by_slot[&CapabilitySlot::Sessions], LOCAL_SESSIONS_PACK);
2220 assert_eq!(by_slot[&CapabilitySlot::State], LOCAL_STATE_PACK);
2221 }
2222}