1use greentic_deploy_spec::engine::RevisionLifecycleError;
29use greentic_deploy_spec::{Environment, Revision, RevisionId, RevisionLifecycle};
30use thiserror::Error;
31
32use crate::environment::{Locked, StoreError};
33
34pub use greentic_deploy_spec::engine::{ActiveSplitRef, HealthCheckId, HealthGateFailure};
39
40#[derive(Debug, Error)]
45pub enum LifecycleError {
46 #[error("revision `{revision_id}` not found in env `{env_id}`")]
48 NotFound {
49 env_id: greentic_deploy_spec::EnvId,
50 revision_id: RevisionId,
51 },
52 #[error("spec rejects transition `{from:?} → {to:?}`")]
58 InvalidTransition {
59 from: RevisionLifecycle,
60 to: RevisionLifecycle,
61 },
62 #[error(
67 "revision `{revision_id}` is in `{actual:?}`; expected one of {expected_starts:?} to apply the requested transition"
68 )]
69 Conflict {
70 revision_id: RevisionId,
71 actual: RevisionLifecycle,
72 expected_starts: Vec<RevisionLifecycle>,
73 },
74 #[error("transition chain is empty; cannot apply any state change")]
76 EmptyChain,
77 #[error(
86 "revision `{revision_id}` is still referenced by {} live traffic split(s); rebalance via `gtc op traffic set` before archiving", splits.len()
87 )]
88 ActiveTrafficReference {
89 revision_id: RevisionId,
90 splits: Vec<ActiveSplitRef>,
91 },
92 #[error(
99 "revision `{revision_id}` failed health gate ({} check(s) failed): {message}",
100 failed_checks.len()
101 )]
102 HealthGateFailed {
103 revision_id: RevisionId,
104 failed_checks: Vec<HealthCheckId>,
105 message: String,
106 },
107 #[error(transparent)]
109 Store(#[from] StoreError),
110}
111
112impl From<RevisionLifecycleError> for LifecycleError {
117 fn from(err: RevisionLifecycleError) -> Self {
118 match err {
119 RevisionLifecycleError::NotFound {
120 env_id,
121 revision_id,
122 } => Self::NotFound {
123 env_id,
124 revision_id,
125 },
126 RevisionLifecycleError::InvalidTransition { from, to } => {
127 Self::InvalidTransition { from, to }
128 }
129 RevisionLifecycleError::Conflict {
130 revision_id,
131 actual,
132 expected_starts,
133 } => Self::Conflict {
134 revision_id,
135 actual,
136 expected_starts,
137 },
138 RevisionLifecycleError::EmptyChain => Self::EmptyChain,
139 RevisionLifecycleError::ActiveTrafficReference {
140 revision_id,
141 splits,
142 } => Self::ActiveTrafficReference {
143 revision_id,
144 splits,
145 },
146 RevisionLifecycleError::HealthGateFailed {
147 revision_id,
148 failed_checks,
149 message,
150 } => Self::HealthGateFailed {
151 revision_id,
152 failed_checks,
153 message,
154 },
155 err @ RevisionLifecycleError::DuplicateRevision { .. } => {
156 Self::Store(StoreError::Conflict(err.to_string()))
157 }
158 err @ RevisionLifecycleError::DeploymentNotFound { .. } => {
159 Self::Store(StoreError::DependentNotFound(err.to_string()))
160 }
161 }
162 }
163}
164
165pub fn apply_revision_transition<F>(
207 locked: &Locked<'_>,
208 revision_id: RevisionId,
209 accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
210 on_final: F,
211 prune_from_splits: bool,
212) -> Result<Revision, LifecycleError>
213where
214 F: FnOnce(&mut Revision),
215{
216 apply_revision_transition_with_health_gate(
217 locked,
218 revision_id,
219 accepted_chain,
220 on_final,
221 prune_from_splits,
222 |_env, _revision| Ok(()),
223 )
224}
225
226pub fn apply_revision_transition_with_health_gate<F, G>(
263 locked: &Locked<'_>,
264 revision_id: RevisionId,
265 accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
266 on_final: F,
267 prune_from_splits: bool,
268 health_gate: G,
269) -> Result<Revision, LifecycleError>
270where
271 F: FnOnce(&mut Revision),
272 G: FnOnce(&Environment, &Revision) -> Result<(), HealthGateFailure>,
273{
274 let mut env = locked.load()?;
280 match greentic_deploy_spec::engine::walk_revision_chain(
281 &mut env,
282 revision_id,
283 accepted_chain,
284 None,
285 on_final,
286 prune_from_splits,
287 health_gate,
288 ) {
289 Ok(transition) => {
290 locked.save(&env)?;
291 Ok(transition.revision)
292 }
293 Err(err) if err.env_mutated() => {
294 locked.save(&env)?;
295 Err(err.into())
296 }
297 Err(err) => Err(err.into()),
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::environment::{EnvironmentStore, LocalFsStore};
305 use chrono::{TimeZone, Utc};
306 use greentic_deploy_spec::{
307 BundleDeployment, BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId,
308 Environment, EnvironmentHostConfig, PackId, PackListEntry, PartyId, RevenueShareEntry,
309 Revision, RevisionId, RevisionLifecycle, RouteBinding, SchemaVersion, SemVer,
310 TenantSelector, TrafficSplit, TrafficSplitEntry,
311 };
312 use std::collections::BTreeMap;
313 use std::path::PathBuf;
314 use tempfile::tempdir;
315
316 const ENV_ID: &str = "local";
317
318 fn env_id() -> EnvId {
319 EnvId::try_from(ENV_ID).unwrap()
320 }
321
322 fn fixed_now() -> chrono::DateTime<Utc> {
323 Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap()
324 }
325
326 fn make_env() -> Environment {
327 Environment {
328 schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
329 environment_id: env_id(),
330 name: ENV_ID.to_string(),
331 host_config: EnvironmentHostConfig {
332 env_id: env_id(),
333 region: None,
334 tenant_org_id: None,
335 listen_addr: None,
336 public_base_url: None,
337 gui_enabled: None,
338 },
339 packs: Vec::new(),
340 credentials_ref: None,
341 bundles: Vec::new(),
342 revisions: Vec::new(),
343 traffic_splits: Vec::new(),
344 messaging_endpoints: Vec::new(),
345 extensions: Vec::new(),
346 revocation: Default::default(),
347 retention: Default::default(),
348 health: Default::default(),
349 }
350 }
351
352 fn make_revision(deployment_id: DeploymentId, lifecycle: RevisionLifecycle) -> Revision {
353 Revision {
354 schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
355 revision_id: RevisionId::new(),
356 env_id: env_id(),
357 bundle_id: BundleId::new("fast2flow"),
358 deployment_id,
359 sequence: 1,
360 created_at: fixed_now(),
361 bundle_digest: "sha256:00".to_string(),
362 bundle_source_uri: None,
363 pack_list: vec![PackListEntry {
364 pack_id: PackId::new("greentic.test.pack"),
365 version: SemVer::new(1, 0, 0),
366 digest: "sha256:00".to_string(),
367 source_uri: None,
368 }],
369 pack_list_lock_ref: PathBuf::from("pack-list.lock"),
370 pack_config_refs: Vec::new(),
371 config_digest: "sha256:00".to_string(),
372 signature_sidecar_ref: PathBuf::from("rev.sig"),
373 lifecycle,
374 staged_at: None,
375 warmed_at: None,
376 drain_seconds: 30,
377 abort_metrics: Vec::new(),
378 }
379 }
380
381 fn make_bundle_deployment() -> BundleDeployment {
382 BundleDeployment {
383 schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
384 deployment_id: DeploymentId::new(),
385 env_id: env_id(),
386 bundle_id: BundleId::new("fast2flow"),
387 customer_id: CustomerId::new("local-dev"),
388 status: BundleDeploymentStatus::Active,
389 current_revisions: Vec::new(),
390 route_binding: RouteBinding {
391 hosts: vec!["fast2flow.local".to_string()],
392 path_prefixes: Vec::new(),
393 tenant_selector: TenantSelector {
394 tenant: "default".to_string(),
395 team: "default".to_string(),
396 },
397 },
398 revenue_share: vec![RevenueShareEntry {
399 party_id: PartyId::new("greentic"),
400 basis_points: 10_000,
401 }],
402 revenue_policy_ref: PathBuf::from("revenue.json"),
403 usage: None,
404 created_at: fixed_now(),
405 authorization_ref: PathBuf::from("auth.json"),
406 config_overrides: BTreeMap::new(),
407 }
408 }
409
410 fn seed_one_revision(lifecycle: RevisionLifecycle) -> (LocalFsStore, EnvId, RevisionId) {
413 let dir = tempdir().unwrap();
414 let store = LocalFsStore::new(dir.path().to_path_buf());
415 let mut env = make_env();
416 let bundle = make_bundle_deployment();
417 let did = bundle.deployment_id;
418 let revision = make_revision(did, lifecycle);
419 let rid = revision.revision_id;
420 env.bundles.push(bundle);
421 env.bundles[0].current_revisions.push(rid);
422 env.revisions.push(revision);
423 store.save(&env).unwrap();
424 std::mem::forget(dir);
429 (store, env_id(), rid)
430 }
431
432 #[test]
433 fn applies_two_hop_chain_to_final_state() {
434 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
435 let revision = store
436 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
437 apply_revision_transition(
438 locked,
439 rid,
440 &[
441 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
442 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
443 ],
444 |_| {},
445 false,
446 )
447 })
448 .unwrap();
449 assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
450
451 let env = store.load(&env_id).unwrap();
453 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
454 }
455
456 #[test]
457 fn on_final_runs_once_on_post_advance_revision() {
458 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
459 let revision = store
460 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
461 apply_revision_transition(
462 locked,
463 rid,
464 &[
465 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
466 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
467 ],
468 |r| {
469 r.warmed_at = Some(fixed_now());
470 },
471 false,
472 )
473 })
474 .unwrap();
475 assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
476 assert_eq!(revision.warmed_at, Some(fixed_now()));
477 }
478
479 #[test]
480 fn missing_revision_surfaces_not_found_without_touching_env() {
481 let (store, env_id, _rid) = seed_one_revision(RevisionLifecycle::Staged);
482 let ghost = RevisionId::new();
483 let err = store
484 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
485 apply_revision_transition(
486 locked,
487 ghost,
488 &[(RevisionLifecycle::Staged, RevisionLifecycle::Warming)],
489 |_| {},
490 false,
491 )
492 })
493 .unwrap_err();
494 match err {
495 LifecycleError::NotFound {
496 revision_id,
497 env_id: e,
498 } => {
499 assert_eq!(revision_id, ghost);
500 assert_eq!(e, env_id);
501 }
502 other => panic!("expected NotFound, got `{other:?}`"),
503 }
504
505 let env = store.load(&env_id).unwrap();
507 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Staged);
508 }
509
510 #[test]
511 fn revision_outside_chain_surfaces_conflict() {
512 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Draining);
516 let err = store
517 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
518 apply_revision_transition(
519 locked,
520 rid,
521 &[
522 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
523 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
524 ],
525 |_| {},
526 false,
527 )
528 })
529 .unwrap_err();
530 match err {
531 LifecycleError::Conflict {
532 revision_id,
533 actual,
534 expected_starts,
535 } => {
536 assert_eq!(revision_id, rid);
537 assert_eq!(actual, RevisionLifecycle::Draining);
538 assert_eq!(
539 expected_starts,
540 vec![RevisionLifecycle::Staged, RevisionLifecycle::Warming]
541 );
542 }
543 other => panic!("expected Conflict, got `{other:?}`"),
544 }
545
546 let env = store.load(&env_id).unwrap();
548 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Draining);
549 }
550
551 #[test]
552 fn already_in_final_state_is_idempotent_success() {
553 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
557 let revision = store
558 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
559 apply_revision_transition(
560 locked,
561 rid,
562 &[
563 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
564 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
565 ],
566 |_| {},
567 false,
568 )
569 })
570 .unwrap();
571 assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
572 }
573
574 #[test]
575 fn empty_chain_returns_empty_chain_error() {
576 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
577 let err = store
578 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
579 apply_revision_transition(locked, rid, &[], |_| {}, false)
580 })
581 .unwrap_err();
582 assert!(matches!(err, LifecycleError::EmptyChain));
583 }
584
585 #[test]
586 fn archive_succeeds_and_prunes_current_revisions_when_no_live_traffic() {
587 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
592
593 let archived = store
594 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
595 apply_revision_transition(
596 locked,
597 rid,
598 &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
599 |_| {},
600 true,
601 )
602 })
603 .unwrap();
604 assert_eq!(archived.lifecycle, RevisionLifecycle::Archived);
605
606 let env = store.load(&env_id).unwrap();
607 assert!(env.bundles[0].current_revisions.is_empty());
608 assert!(env.traffic_splits.is_empty());
609 }
610
611 #[test]
612 fn archive_refuses_when_revision_owns_100_percent_of_a_split() {
613 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
618 let mut env = store.load(&env_id).unwrap();
619 let did = env.bundles[0].deployment_id;
620 env.traffic_splits.push(TrafficSplit {
621 schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
622 env_id: env_id.clone(),
623 deployment_id: did,
624 bundle_id: BundleId::new("fast2flow"),
625 generation: 0,
626 entries: vec![TrafficSplitEntry {
627 revision_id: rid,
628 weight_bps: 10_000,
629 }],
630 updated_at: fixed_now(),
631 updated_by: "test".to_string(),
632 idempotency_key: "k1".to_string(),
633 authorization_ref: PathBuf::from("auth.json"),
634 previous_split_ref: None,
635 });
636 env.bundles[0].current_revisions.push(rid);
637 store.save(&env).unwrap();
638
639 let err = store
640 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
641 apply_revision_transition(
642 locked,
643 rid,
644 &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
645 |_| {},
646 true,
647 )
648 })
649 .unwrap_err();
650 match err {
651 LifecycleError::ActiveTrafficReference {
652 revision_id,
653 splits,
654 } => {
655 assert_eq!(revision_id, rid);
656 assert_eq!(splits.len(), 1);
657 assert_eq!(splits[0].deployment_id, did);
658 assert_eq!(splits[0].weight_bps, 10_000);
659 }
660 other => panic!("expected ActiveTrafficReference, got `{other:?}`"),
661 }
662
663 let env = store.load(&env_id).unwrap();
666 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
667 assert_eq!(env.traffic_splits.len(), 1);
668 assert_eq!(env.traffic_splits[0].entries.len(), 1);
669 assert!(env.bundles[0].current_revisions.contains(&rid));
670 }
671
672 #[test]
673 fn archive_refuses_when_revision_owns_partial_traffic_in_a_split() {
674 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
679 let mut env = store.load(&env_id).unwrap();
680 let did = env.bundles[0].deployment_id;
681
682 let main_revision = make_revision(did, RevisionLifecycle::Ready);
684 let main_rid = main_revision.revision_id;
685 env.revisions.push(main_revision);
686 env.bundles[0].current_revisions.push(rid);
687 env.bundles[0].current_revisions.push(main_rid);
688 env.traffic_splits.push(TrafficSplit {
689 schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
690 env_id: env_id.clone(),
691 deployment_id: did,
692 bundle_id: BundleId::new("fast2flow"),
693 generation: 0,
694 entries: vec![
695 TrafficSplitEntry {
696 revision_id: rid,
697 weight_bps: 3_000,
698 },
699 TrafficSplitEntry {
700 revision_id: main_rid,
701 weight_bps: 7_000,
702 },
703 ],
704 updated_at: fixed_now(),
705 updated_by: "test".to_string(),
706 idempotency_key: "k1".to_string(),
707 authorization_ref: PathBuf::from("auth.json"),
708 previous_split_ref: None,
709 });
710 store.save(&env).unwrap();
711
712 let err = store
713 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
714 apply_revision_transition(
715 locked,
716 rid,
717 &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
718 |_| {},
719 true,
720 )
721 })
722 .unwrap_err();
723 match err {
724 LifecycleError::ActiveTrafficReference {
725 revision_id,
726 splits,
727 } => {
728 assert_eq!(revision_id, rid);
729 assert_eq!(splits.len(), 1);
730 assert_eq!(splits[0].weight_bps, 3_000);
731 }
732 other => panic!("expected ActiveTrafficReference, got `{other:?}`"),
733 }
734
735 let env = store.load(&env_id).unwrap();
737 let sum: u32 = env.traffic_splits[0]
738 .entries
739 .iter()
740 .map(|e| e.weight_bps)
741 .sum();
742 assert_eq!(sum, 10_000);
743 }
744
745 #[test]
746 fn drain_then_archive_walk_retires_a_live_revision_to_terminal() {
747 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Draining);
753 let mut env = store.load(&env_id).unwrap();
755 env.revisions[0].lifecycle = RevisionLifecycle::Inactive;
756 store.save(&env).unwrap();
757
758 let archived = store
759 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
760 apply_revision_transition(
761 locked,
762 rid,
763 &[
764 (RevisionLifecycle::Staged, RevisionLifecycle::Archived),
765 (RevisionLifecycle::Warming, RevisionLifecycle::Archived),
766 (RevisionLifecycle::Ready, RevisionLifecycle::Archived),
767 (RevisionLifecycle::Failed, RevisionLifecycle::Archived),
768 (RevisionLifecycle::Draining, RevisionLifecycle::Inactive),
769 (RevisionLifecycle::Inactive, RevisionLifecycle::Archived),
770 ],
771 |_| {},
772 true,
773 )
774 })
775 .unwrap();
776 assert_eq!(archived.lifecycle, RevisionLifecycle::Archived);
777 }
778
779 #[test]
780 fn matrix_walks_every_legal_outbound_edge() {
781 use RevisionLifecycle::*;
786 for (from, to) in &[
787 (Inactive, Staged),
788 (Inactive, Failed),
789 (Inactive, Archived),
790 (Staged, Warming),
791 (Staged, Failed),
792 (Staged, Archived),
793 (Warming, Ready),
794 (Warming, Failed),
795 (Warming, Archived),
796 (Ready, Draining),
797 (Ready, Failed),
798 (Ready, Archived),
799 (Draining, Inactive),
800 (Failed, Staged),
801 (Failed, Archived),
802 ] {
803 let (store, env_id, rid) = seed_one_revision(*from);
804 let result = store.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
805 apply_revision_transition(locked, rid, &[(*from, *to)], |_| {}, false)
806 });
807 assert!(
808 result.is_ok(),
809 "matrix edge `{from:?} → {to:?}` was rejected: {:?}",
810 result.err()
811 );
812 assert_eq!(result.unwrap().lifecycle, *to);
813 }
814 }
815
816 #[test]
821 fn health_gate_pass_advances_chain_and_runs_on_final() {
822 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
823 let revision = store
824 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
825 apply_revision_transition_with_health_gate(
826 locked,
827 rid,
828 &[
829 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
830 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
831 ],
832 |r| r.warmed_at = Some(fixed_now()),
833 false,
834 |_env, rev| {
835 assert_eq!(rev.lifecycle, RevisionLifecycle::Ready);
837 Ok(())
838 },
839 )
840 })
841 .unwrap();
842 assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
843 assert_eq!(revision.warmed_at, Some(fixed_now()));
844
845 let env = store.load(&env_id).unwrap();
846 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
847 assert_eq!(env.revisions[0].warmed_at, Some(fixed_now()));
848 }
849
850 #[test]
853 fn health_gate_failure_persists_failed_and_skips_on_final() {
854 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
855 let on_final_ran = std::cell::Cell::new(false);
856 let err = store
857 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
858 apply_revision_transition_with_health_gate(
859 locked,
860 rid,
861 &[
862 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
863 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
864 ],
865 |r| {
866 on_final_ran.set(true);
867 r.warmed_at = Some(fixed_now());
868 },
869 false,
870 |_env, _rev| {
871 Err(HealthGateFailure {
872 failed_checks: vec![
873 HealthCheckId::RuntimeConfig,
874 HealthCheckId::SignatureStatus,
875 ],
876 message: "synthetic test failure".to_string(),
877 })
878 },
879 )
880 })
881 .unwrap_err();
882 match err {
883 LifecycleError::HealthGateFailed {
884 revision_id,
885 failed_checks,
886 message,
887 } => {
888 assert_eq!(revision_id, rid);
889 assert_eq!(
890 failed_checks,
891 vec![HealthCheckId::RuntimeConfig, HealthCheckId::SignatureStatus]
892 );
893 assert!(message.contains("synthetic"));
894 }
895 other => panic!("expected HealthGateFailed, got `{other:?}`"),
896 }
897 assert!(
898 !on_final_ran.get(),
899 "on_final must not run on health-gate failure"
900 );
901
902 let env = store.load(&env_id).unwrap();
904 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
905 assert_eq!(env.revisions[0].warmed_at, None);
906 }
907
908 #[test]
916 fn idempotent_retry_skips_gate_and_preserves_ready() {
917 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
918 let gate_invoked = std::cell::Cell::new(false);
919 let revision = store
920 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
921 apply_revision_transition_with_health_gate(
922 locked,
923 rid,
924 &[
925 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
926 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
927 ],
928 |r| r.warmed_at = Some(fixed_now()),
929 false,
930 |_env, _rev| {
931 gate_invoked.set(true);
932 Err(HealthGateFailure {
933 failed_checks: vec![HealthCheckId::ProviderHealth],
934 message: "would have demoted a live revision".to_string(),
935 })
936 },
937 )
938 })
939 .unwrap();
940 assert!(
941 !gate_invoked.get(),
942 "gate must not run on idempotent retry against an already-final revision"
943 );
944 assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
945 assert_eq!(revision.warmed_at, Some(fixed_now()));
946
947 let env = store.load(&env_id).unwrap();
948 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
949 }
950
951 #[test]
957 fn gate_skipped_on_retry_preserves_live_routed_revision() {
958 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
959 let mut env = store.load(&env_id).unwrap();
960 let did = env.bundles[0].deployment_id;
961 env.traffic_splits.push(TrafficSplit {
962 schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
963 env_id: env_id.clone(),
964 deployment_id: did,
965 bundle_id: BundleId::new("fast2flow"),
966 generation: 0,
967 entries: vec![TrafficSplitEntry {
968 revision_id: rid,
969 weight_bps: 10_000,
970 }],
971 updated_at: fixed_now(),
972 updated_by: "test".to_string(),
973 idempotency_key: "k1".to_string(),
974 authorization_ref: PathBuf::from("auth.json"),
975 previous_split_ref: None,
976 });
977 store.save(&env).unwrap();
978
979 let revision = store
980 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
981 apply_revision_transition_with_health_gate(
982 locked,
983 rid,
984 &[
985 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
986 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
987 ],
988 |_| {},
989 false,
990 |_env, _rev| {
991 Err(HealthGateFailure {
992 failed_checks: vec![HealthCheckId::ProviderHealth],
993 message: "transient — must not demote".to_string(),
994 })
995 },
996 )
997 })
998 .unwrap();
999 assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
1000
1001 let env = store.load(&env_id).unwrap();
1003 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1004 assert_eq!(env.traffic_splits.len(), 1);
1005 assert_eq!(env.traffic_splits[0].entries.len(), 1);
1006 assert_eq!(env.traffic_splits[0].entries[0].revision_id, rid);
1007 assert_eq!(env.traffic_splits[0].entries[0].weight_bps, 10_000);
1008 }
1009
1010 #[test]
1014 fn apply_revision_transition_remains_a_noop_gate_wrapper() {
1015 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
1016 let revision = store
1017 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
1018 apply_revision_transition(
1019 locked,
1020 rid,
1021 &[
1022 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
1023 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
1024 ],
1025 |r| r.warmed_at = Some(fixed_now()),
1026 false,
1027 )
1028 })
1029 .unwrap();
1030 assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
1031 assert_eq!(revision.warmed_at, Some(fixed_now()));
1032 }
1033
1034 #[test]
1040 fn gate_failure_with_no_legal_failed_transition_surfaces_invalid_transition() {
1041 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
1045 let err = store
1046 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
1047 apply_revision_transition_with_health_gate(
1048 locked,
1049 rid,
1050 &[(RevisionLifecycle::Ready, RevisionLifecycle::Draining)],
1051 |_| {},
1052 false,
1053 |_env, _rev| {
1054 Err(HealthGateFailure {
1055 failed_checks: vec![HealthCheckId::RouteTable],
1056 message: "unreachable in practice for drain".to_string(),
1057 })
1058 },
1059 )
1060 })
1061 .unwrap_err();
1062 match err {
1063 LifecycleError::InvalidTransition { from, to } => {
1064 assert_eq!(from, RevisionLifecycle::Draining);
1065 assert_eq!(to, RevisionLifecycle::Failed);
1066 }
1067 other => panic!("expected InvalidTransition, got `{other:?}`"),
1068 }
1069
1070 let env = store.load(&env_id).unwrap();
1072 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1073 }
1074
1075 #[test]
1081 fn gate_is_not_invoked_when_prune_guard_refuses() {
1082 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
1083 let mut env = store.load(&env_id).unwrap();
1084 let did = env.bundles[0].deployment_id;
1085 env.traffic_splits.push(TrafficSplit {
1086 schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
1087 env_id: env_id.clone(),
1088 deployment_id: did,
1089 bundle_id: BundleId::new("fast2flow"),
1090 generation: 0,
1091 entries: vec![TrafficSplitEntry {
1092 revision_id: rid,
1093 weight_bps: 10_000,
1094 }],
1095 updated_at: fixed_now(),
1096 updated_by: "test".to_string(),
1097 idempotency_key: "k1".to_string(),
1098 authorization_ref: PathBuf::from("auth.json"),
1099 previous_split_ref: None,
1100 });
1101 store.save(&env).unwrap();
1102
1103 let gate_invoked = std::cell::Cell::new(false);
1104 let err = store
1105 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
1106 apply_revision_transition_with_health_gate(
1107 locked,
1108 rid,
1109 &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
1110 |_| {},
1111 true,
1112 |_env, _rev| {
1113 gate_invoked.set(true);
1114 Ok(())
1115 },
1116 )
1117 })
1118 .unwrap_err();
1119 assert!(matches!(err, LifecycleError::ActiveTrafficReference { .. }));
1120 assert!(
1121 !gate_invoked.get(),
1122 "gate must not run when prune guard refuses"
1123 );
1124 }
1125
1126 #[test]
1130 fn gate_failure_with_empty_failed_checks_is_still_persisted() {
1131 let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
1132 let err = store
1133 .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
1134 apply_revision_transition_with_health_gate(
1135 locked,
1136 rid,
1137 &[
1138 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
1139 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
1140 ],
1141 |_| {},
1142 false,
1143 |_env, _rev| {
1144 Err(HealthGateFailure {
1145 failed_checks: Vec::new(),
1146 message: "gate aborted before any check completed".to_string(),
1147 })
1148 },
1149 )
1150 })
1151 .unwrap_err();
1152 match err {
1153 LifecycleError::HealthGateFailed {
1154 failed_checks,
1155 message,
1156 ..
1157 } => {
1158 assert!(failed_checks.is_empty());
1159 assert!(message.contains("aborted"));
1160 }
1161 other => panic!("expected HealthGateFailed, got `{other:?}`"),
1162 }
1163 let env = store.load(&env_id).unwrap();
1164 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
1165 }
1166}