1use chrono::{DateTime, Utc};
34use serde::{Deserialize, Serialize};
35use std::path::PathBuf;
36use thiserror::Error;
37
38use crate::environment::Environment;
39use crate::ids::{BundleId, DeploymentId, RevisionId};
40use crate::revision::{PackListEntry, Revision, RevisionLifecycle, is_valid_transition};
41use crate::version::SchemaVersion;
42use greentic_types::EnvId;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum HealthCheckId {
57 RouteTable,
59 RuntimeConfig,
61 SignatureStatus,
63 ProviderHealth,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct HealthGateFailure {
77 pub failed_checks: Vec<HealthCheckId>,
78 pub message: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ActiveSplitRef {
87 pub deployment_id: DeploymentId,
88 pub bundle_id: BundleId,
89 pub weight_bps: u32,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Error)]
105pub enum RevisionLifecycleError {
106 #[error("revision `{revision_id}` not found in env `{env_id}`")]
108 NotFound {
109 env_id: EnvId,
110 revision_id: RevisionId,
111 },
112 #[error("spec rejects transition `{from:?} → {to:?}`")]
116 InvalidTransition {
117 from: RevisionLifecycle,
118 to: RevisionLifecycle,
119 },
120 #[error(
126 "revision `{revision_id}` is in `{actual:?}`; expected one of {expected_starts:?} to apply the requested transition"
127 )]
128 Conflict {
129 revision_id: RevisionId,
130 actual: RevisionLifecycle,
131 expected_starts: Vec<RevisionLifecycle>,
132 },
133 #[error("transition chain is empty; cannot apply any state change")]
135 EmptyChain,
136 #[error(
141 "revision `{revision_id}` is still referenced by {} live traffic split(s); rebalance via `gtc op traffic set` before archiving", splits.len()
142 )]
143 ActiveTrafficReference {
144 revision_id: RevisionId,
145 splits: Vec<ActiveSplitRef>,
146 },
147 #[error(
152 "revision `{revision_id}` failed health gate ({} check(s) failed): {message}",
153 failed_checks.len()
154 )]
155 HealthGateFailed {
156 revision_id: RevisionId,
157 failed_checks: Vec<HealthCheckId>,
158 message: String,
159 },
160 #[error("revision `{revision_id}` already exists in env `{env_id}`")]
167 DuplicateRevision {
168 env_id: EnvId,
169 revision_id: RevisionId,
170 },
171 #[error("deployment `{deployment_id}` not found in env `{env_id}`")]
173 DeploymentNotFound {
174 env_id: EnvId,
175 deployment_id: DeploymentId,
176 },
177}
178
179impl RevisionLifecycleError {
180 pub fn env_mutated(&self) -> bool {
185 matches!(self, Self::HealthGateFailed { .. })
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct StageRevisionPayload {
201 pub revision_id: RevisionId,
202 pub deployment_id: DeploymentId,
203 pub bundle_digest: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub bundle_source_uri: Option<String>,
209 pub pack_list: Vec<PackListEntry>,
210 pub pack_list_lock_ref: PathBuf,
211 pub pack_config_refs: Vec<PathBuf>,
212 pub config_digest: String,
213 pub signature_sidecar_ref: PathBuf,
214 pub drain_seconds: u32,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct WarmRevisionPayload {
228 pub revision_id: RevisionId,
229 #[serde(with = "health_gate_wire")]
232 pub health_gate: Result<(), HealthGateFailure>,
233 pub expected_lifecycle: RevisionLifecycle,
242}
243
244mod health_gate_wire {
248 use super::HealthGateFailure;
249 use serde::{Deserialize, Deserializer, Serialize, Serializer};
250
251 #[derive(Serialize, Deserialize)]
252 #[serde(deny_unknown_fields)]
253 struct Repr {
254 ok: bool,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 failure: Option<HealthGateFailure>,
257 }
258
259 pub fn serialize<S: Serializer>(
260 value: &Result<(), HealthGateFailure>,
261 serializer: S,
262 ) -> Result<S::Ok, S::Error> {
263 let repr = match value {
264 Ok(()) => Repr {
265 ok: true,
266 failure: None,
267 },
268 Err(failure) => Repr {
269 ok: false,
270 failure: Some(failure.clone()),
271 },
272 };
273 repr.serialize(serializer)
274 }
275
276 pub fn deserialize<'de, D: Deserializer<'de>>(
277 deserializer: D,
278 ) -> Result<Result<(), HealthGateFailure>, D::Error> {
279 let repr = Repr::deserialize(deserializer)?;
280 match (repr.ok, repr.failure) {
281 (true, None) => Ok(Ok(())),
282 (false, Some(failure)) => Ok(Err(failure)),
283 (true, Some(_)) => Err(serde::de::Error::custom(
284 "health gate cannot be `ok: true` and carry a `failure`",
285 )),
286 (false, None) => Err(serde::de::Error::custom(
287 "health gate `ok: false` must carry a `failure`",
288 )),
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct RevisionTransitionOutcome {
300 pub revision: Revision,
301 pub environment: Environment,
302 pub starting_lifecycle: RevisionLifecycle,
303}
304
305#[derive(Debug, Clone)]
309pub struct RevisionTransition {
310 pub revision: Revision,
311 pub starting_lifecycle: RevisionLifecycle,
312}
313
314pub fn walk_revision_chain<F, G>(
348 env: &mut Environment,
349 revision_id: RevisionId,
350 accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
351 expected_start: Option<RevisionLifecycle>,
352 on_final: F,
353 prune_from_splits: bool,
354 health_gate: G,
355) -> Result<RevisionTransition, RevisionLifecycleError>
356where
357 F: FnOnce(&mut Revision),
358 G: FnOnce(&Environment, &Revision) -> Result<(), HealthGateFailure>,
359{
360 if accepted_chain.is_empty() {
361 return Err(RevisionLifecycleError::EmptyChain);
362 }
363 let final_state = accepted_chain
364 .last()
365 .map(|(_, to)| *to)
366 .expect("chain non-empty: checked above");
367
368 let idx = env
369 .revisions
370 .iter()
371 .position(|r| r.revision_id == revision_id)
372 .ok_or_else(|| RevisionLifecycleError::NotFound {
373 env_id: env.environment_id.clone(),
374 revision_id,
375 })?;
376 let starting_lifecycle = env.revisions[idx].lifecycle;
377
378 if let Some(expected) = expected_start
379 && starting_lifecycle != final_state
380 && starting_lifecycle != expected
381 {
382 return Err(RevisionLifecycleError::Conflict {
383 revision_id,
384 actual: starting_lifecycle,
385 expected_starts: vec![expected],
386 });
387 }
388
389 let mut chain_advanced = false;
390 for (from, to) in accepted_chain {
391 if env.revisions[idx].lifecycle == *from {
392 if !is_valid_transition(*from, *to) {
393 return Err(RevisionLifecycleError::InvalidTransition {
394 from: *from,
395 to: *to,
396 });
397 }
398 env.revisions[idx].lifecycle = *to;
399 chain_advanced = true;
400 }
401 }
402
403 if env.revisions[idx].lifecycle != final_state {
404 let expected_starts = accepted_chain.iter().map(|(from, _)| *from).collect();
405 return Err(RevisionLifecycleError::Conflict {
406 revision_id,
407 actual: env.revisions[idx].lifecycle,
408 expected_starts,
409 });
410 }
411
412 if prune_from_splits {
413 let active_refs: Vec<ActiveSplitRef> = env
418 .traffic_splits
419 .iter()
420 .flat_map(|split| {
421 split
422 .entries
423 .iter()
424 .filter(|entry| entry.revision_id == revision_id)
425 .map(|entry| ActiveSplitRef {
426 deployment_id: split.deployment_id,
427 bundle_id: split.bundle_id.clone(),
428 weight_bps: entry.weight_bps,
429 })
430 })
431 .collect();
432 if !active_refs.is_empty() {
433 return Err(RevisionLifecycleError::ActiveTrafficReference {
434 revision_id,
435 splits: active_refs,
436 });
437 }
438 }
439
440 if chain_advanced && let Err(failure) = health_gate(env, &env.revisions[idx]) {
446 let prior = env.revisions[idx].lifecycle;
447 if !is_valid_transition(prior, RevisionLifecycle::Failed) {
448 return Err(RevisionLifecycleError::InvalidTransition {
452 from: prior,
453 to: RevisionLifecycle::Failed,
454 });
455 }
456 env.revisions[idx].lifecycle = RevisionLifecycle::Failed;
457 return Err(RevisionLifecycleError::HealthGateFailed {
458 revision_id,
459 failed_checks: failure.failed_checks,
460 message: failure.message,
461 });
462 }
463
464 on_final(&mut env.revisions[idx]);
465
466 if prune_from_splits {
467 let deployment_id = env.revisions[idx].deployment_id;
471 for bundle in env.bundles.iter_mut() {
472 if bundle.deployment_id == deployment_id {
473 bundle.current_revisions.retain(|rid| *rid != revision_id);
474 }
475 }
476 }
477
478 Ok(RevisionTransition {
479 revision: env.revisions[idx].clone(),
480 starting_lifecycle,
481 })
482}
483
484pub fn stage_revision(
488 env: &mut Environment,
489 payload: StageRevisionPayload,
490 now: DateTime<Utc>,
491) -> Result<Revision, RevisionLifecycleError> {
492 if env
493 .revisions
494 .iter()
495 .any(|r| r.revision_id == payload.revision_id)
496 {
497 return Err(RevisionLifecycleError::DuplicateRevision {
498 env_id: env.environment_id.clone(),
499 revision_id: payload.revision_id,
500 });
501 }
502 let bundle_id = env
503 .bundles
504 .iter()
505 .find(|b| b.deployment_id == payload.deployment_id)
506 .map(|b| b.bundle_id.clone())
507 .ok_or_else(|| RevisionLifecycleError::DeploymentNotFound {
508 env_id: env.environment_id.clone(),
509 deployment_id: payload.deployment_id,
510 })?;
511 let next_sequence = env
512 .revisions
513 .iter()
514 .filter(|r| r.deployment_id == payload.deployment_id)
515 .map(|r| r.sequence)
516 .max()
517 .unwrap_or(0)
518 + 1;
519 let revision = Revision {
520 schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
521 revision_id: payload.revision_id,
522 env_id: env.environment_id.clone(),
523 bundle_id,
524 deployment_id: payload.deployment_id,
525 sequence: next_sequence,
526 created_at: now,
527 bundle_digest: payload.bundle_digest,
528 bundle_source_uri: payload.bundle_source_uri,
529 pack_list: payload.pack_list,
530 pack_list_lock_ref: payload.pack_list_lock_ref,
531 pack_config_refs: payload.pack_config_refs,
532 config_digest: payload.config_digest,
533 signature_sidecar_ref: payload.signature_sidecar_ref,
534 lifecycle: RevisionLifecycle::Staged,
535 staged_at: Some(now),
536 warmed_at: None,
537 drain_seconds: payload.drain_seconds,
538 abort_metrics: Vec::new(),
539 };
540 env.revisions.push(revision.clone());
541 Ok(revision)
542}
543
544pub fn warm_revision(
556 env: &mut Environment,
557 payload: WarmRevisionPayload,
558 now: DateTime<Utc>,
559) -> Result<RevisionTransition, RevisionLifecycleError> {
560 let WarmRevisionPayload {
561 revision_id,
562 health_gate,
563 expected_lifecycle,
564 } = payload;
565 walk_revision_chain(
566 env,
567 revision_id,
568 &[
569 (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
570 (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
571 ],
572 Some(expected_lifecycle),
573 |r| {
574 r.warmed_at = Some(now);
575 },
576 false,
577 |_env, _rev| health_gate,
580 )
581}
582
583pub fn drain_revision(
587 env: &mut Environment,
588 revision_id: RevisionId,
589) -> Result<RevisionTransition, RevisionLifecycleError> {
590 walk_revision_chain(
591 env,
592 revision_id,
593 &[(RevisionLifecycle::Ready, RevisionLifecycle::Draining)],
594 None,
595 |_| {},
596 false,
597 |_env, _rev| Ok(()),
598 )
599}
600
601pub fn archive_revision(
606 env: &mut Environment,
607 revision_id: RevisionId,
608) -> Result<RevisionTransition, RevisionLifecycleError> {
609 walk_revision_chain(
610 env,
611 revision_id,
612 &[
613 (RevisionLifecycle::Staged, RevisionLifecycle::Archived),
614 (RevisionLifecycle::Warming, RevisionLifecycle::Archived),
615 (RevisionLifecycle::Ready, RevisionLifecycle::Archived),
616 (RevisionLifecycle::Failed, RevisionLifecycle::Archived),
617 (RevisionLifecycle::Draining, RevisionLifecycle::Inactive),
618 (RevisionLifecycle::Inactive, RevisionLifecycle::Archived),
619 ],
620 None,
621 |_| {},
622 true,
623 |_env, _rev| Ok(()),
624 )
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use crate::bundle_deployment::{
631 BundleDeployment, BundleDeploymentStatus, RevenueShareEntry, RouteBinding, TenantSelector,
632 };
633 use crate::ids::{CustomerId, PackId, PartyId};
634 use crate::traffic_split::{TrafficSplit, TrafficSplitEntry};
635 use crate::version::SemVer;
636 use chrono::TimeZone;
637 use serde_json::json;
638 use std::collections::BTreeMap;
639
640 fn env_id() -> EnvId {
641 EnvId::try_from("local").unwrap()
642 }
643
644 fn fixed_now() -> DateTime<Utc> {
645 Utc.with_ymd_and_hms(2026, 6, 12, 12, 0, 0).unwrap()
646 }
647
648 fn deployment(deployment_id: DeploymentId) -> BundleDeployment {
649 BundleDeployment {
650 schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
651 deployment_id,
652 env_id: env_id(),
653 bundle_id: BundleId::new("fast2flow"),
654 customer_id: CustomerId::new("local-dev"),
655 status: BundleDeploymentStatus::Active,
656 current_revisions: Vec::new(),
657 route_binding: RouteBinding {
658 hosts: vec!["fast2flow.local".to_string()],
659 path_prefixes: Vec::new(),
660 tenant_selector: TenantSelector {
661 tenant: "default".to_string(),
662 team: "default".to_string(),
663 },
664 },
665 revenue_share: vec![RevenueShareEntry {
666 party_id: PartyId::new("greentic"),
667 basis_points: 10_000,
668 }],
669 revenue_policy_ref: PathBuf::from("revenue.json"),
670 usage: None,
671 created_at: fixed_now(),
672 authorization_ref: PathBuf::from("auth.json"),
673 config_overrides: BTreeMap::new(),
674 }
675 }
676
677 fn env_with_deployment(deployment_id: DeploymentId) -> Environment {
678 let mut env = super::super::fresh_environment(
679 &env_id(),
680 "local".to_string(),
681 crate::environment::EnvironmentHostConfig {
682 env_id: env_id(),
683 region: None,
684 tenant_org_id: None,
685 listen_addr: None,
686 public_base_url: None,
687 gui_enabled: None,
688 },
689 Default::default(),
690 Default::default(),
691 Default::default(),
692 );
693 env.bundles.push(deployment(deployment_id));
694 env
695 }
696
697 fn stage_payload(deployment_id: DeploymentId) -> StageRevisionPayload {
698 StageRevisionPayload {
699 revision_id: RevisionId::new(),
700 deployment_id,
701 bundle_digest: "sha256:00".to_string(),
702 bundle_source_uri: None,
703 pack_list: vec![PackListEntry {
704 pack_id: PackId::new("greentic.test.pack"),
705 version: SemVer::new(1, 0, 0),
706 digest: "sha256:00".to_string(),
707 source_uri: None,
708 }],
709 pack_list_lock_ref: PathBuf::from("pack-list.lock"),
710 pack_config_refs: Vec::new(),
711 config_digest: "sha256:00".to_string(),
712 signature_sidecar_ref: PathBuf::from("rev.sig"),
713 drain_seconds: 30,
714 }
715 }
716
717 fn warm_payload(revision_id: RevisionId) -> WarmRevisionPayload {
718 WarmRevisionPayload {
719 revision_id,
720 health_gate: Ok(()),
721 expected_lifecycle: RevisionLifecycle::Staged,
722 }
723 }
724
725 #[test]
728 fn stage_assigns_per_deployment_sequence_and_staged_lifecycle() {
729 let deployment_id = DeploymentId::new();
730 let mut env = env_with_deployment(deployment_id);
731
732 let first = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
733 assert_eq!(first.sequence, 1);
734 assert_eq!(first.lifecycle, RevisionLifecycle::Staged);
735 assert_eq!(first.staged_at, Some(fixed_now()));
736 assert_eq!(first.bundle_id, BundleId::new("fast2flow"));
737
738 let second = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
739 assert_eq!(second.sequence, 2);
740 assert_eq!(env.revisions.len(), 2);
741 }
742
743 #[test]
746 fn stage_records_bundle_source_uri_when_supplied() {
747 let deployment_id = DeploymentId::new();
748 let mut env = env_with_deployment(deployment_id);
749
750 let local = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
751 assert_eq!(local.bundle_source_uri, None);
752
753 let pullable = StageRevisionPayload {
754 bundle_source_uri: Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc".to_string()),
755 ..stage_payload(deployment_id)
756 };
757 let staged = stage_revision(&mut env, pullable, fixed_now()).unwrap();
758 assert_eq!(
759 staged.bundle_source_uri.as_deref(),
760 Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc")
761 );
762 }
763
764 #[test]
765 fn stage_rejects_duplicate_revision_id() {
766 let deployment_id = DeploymentId::new();
767 let mut env = env_with_deployment(deployment_id);
768
769 let payload = stage_payload(deployment_id);
770 let dup = StageRevisionPayload {
771 revision_id: payload.revision_id,
772 ..stage_payload(deployment_id)
773 };
774 stage_revision(&mut env, payload, fixed_now()).unwrap();
775 let err = stage_revision(&mut env, dup, fixed_now()).unwrap_err();
776 assert_eq!(
777 err,
778 RevisionLifecycleError::DuplicateRevision {
779 env_id: env_id(),
780 revision_id: env.revisions[0].revision_id,
781 }
782 );
783 assert_eq!(env.revisions.len(), 1);
784 assert!(!err.env_mutated());
785 }
786
787 #[test]
788 fn stage_rejects_unknown_deployment() {
789 let mut env = env_with_deployment(DeploymentId::new());
790 let other = DeploymentId::new();
791 let err = stage_revision(&mut env, stage_payload(other), fixed_now()).unwrap_err();
792 assert_eq!(
793 err,
794 RevisionLifecycleError::DeploymentNotFound {
795 env_id: env_id(),
796 deployment_id: other,
797 }
798 );
799 assert!(env.revisions.is_empty());
800 }
801
802 #[test]
805 fn warm_advances_staged_to_ready_and_stamps_warmed_at() {
806 let deployment_id = DeploymentId::new();
807 let mut env = env_with_deployment(deployment_id);
808 let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
809
810 let out = warm_revision(&mut env, warm_payload(rev.revision_id), fixed_now()).unwrap();
811 assert_eq!(out.revision.lifecycle, RevisionLifecycle::Ready);
812 assert_eq!(out.revision.warmed_at, Some(fixed_now()));
813 assert_eq!(out.starting_lifecycle, RevisionLifecycle::Staged);
814 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
815 }
816
817 #[test]
818 fn warm_gate_failure_flips_to_failed_and_marks_env_mutated() {
819 let deployment_id = DeploymentId::new();
820 let mut env = env_with_deployment(deployment_id);
821 let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
822
823 let mut payload = warm_payload(rev.revision_id);
824 payload.health_gate = Err(HealthGateFailure {
825 failed_checks: vec![HealthCheckId::RouteTable],
826 message: "route table invalid".to_string(),
827 });
828 let err = warm_revision(&mut env, payload, fixed_now()).unwrap_err();
829 assert!(err.env_mutated(), "gate failure must demand persistence");
830 assert!(matches!(
831 err,
832 RevisionLifecycleError::HealthGateFailed { ref failed_checks, .. }
833 if failed_checks == &vec![HealthCheckId::RouteTable]
834 ));
835 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
836 assert_eq!(env.revisions[0].warmed_at, None, "on_final must not run");
837 }
838
839 #[test]
840 fn warm_stale_expected_lifecycle_conflicts_without_mutation() {
841 let deployment_id = DeploymentId::new();
842 let mut env = env_with_deployment(deployment_id);
843 let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
844 env.revisions[0].lifecycle = RevisionLifecycle::Warming;
845
846 let err = warm_revision(&mut env, warm_payload(rev.revision_id), fixed_now()).unwrap_err();
848 assert!(matches!(
849 err,
850 RevisionLifecycleError::Conflict {
851 actual: RevisionLifecycle::Warming,
852 ..
853 }
854 ));
855 assert!(!err.env_mutated());
856 }
857
858 #[test]
859 fn warm_idempotent_retry_skips_precondition_and_gate() {
860 let deployment_id = DeploymentId::new();
861 let mut env = env_with_deployment(deployment_id);
862 let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
863 env.revisions[0].lifecycle = RevisionLifecycle::Ready;
864
865 let mut payload = warm_payload(rev.revision_id);
867 payload.health_gate = Err(HealthGateFailure {
868 failed_checks: vec![],
869 message: "transient".to_string(),
870 });
871 let later = Utc.with_ymd_and_hms(2026, 6, 12, 13, 0, 0).unwrap();
872 let out = warm_revision(&mut env, payload, later).unwrap();
873 assert_eq!(out.revision.lifecycle, RevisionLifecycle::Ready);
874 assert_eq!(out.revision.warmed_at, Some(later), "on_final re-stamps");
875 assert_eq!(out.starting_lifecycle, RevisionLifecycle::Ready);
876 }
877
878 #[test]
881 fn drain_requires_ready() {
882 let deployment_id = DeploymentId::new();
883 let mut env = env_with_deployment(deployment_id);
884 let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
885
886 let err = drain_revision(&mut env, rev.revision_id).unwrap_err();
887 assert!(matches!(err, RevisionLifecycleError::Conflict { .. }));
888
889 env.revisions[0].lifecycle = RevisionLifecycle::Ready;
890 let out = drain_revision(&mut env, rev.revision_id).unwrap();
891 assert_eq!(out.revision.lifecycle, RevisionLifecycle::Draining);
892 assert_eq!(out.starting_lifecycle, RevisionLifecycle::Ready);
893 }
894
895 #[test]
896 fn archive_walks_draining_to_archived_and_prunes_tracking_list() {
897 let deployment_id = DeploymentId::new();
898 let mut env = env_with_deployment(deployment_id);
899 let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
900 env.revisions[0].lifecycle = RevisionLifecycle::Draining;
901 env.bundles[0].current_revisions.push(rev.revision_id);
902
903 let out = archive_revision(&mut env, rev.revision_id).unwrap();
904 assert_eq!(out.revision.lifecycle, RevisionLifecycle::Archived);
905 assert_eq!(out.starting_lifecycle, RevisionLifecycle::Draining);
906 assert!(env.bundles[0].current_revisions.is_empty());
907 }
908
909 #[test]
910 fn archive_refuses_live_traffic_reference() {
911 let deployment_id = DeploymentId::new();
912 let mut env = env_with_deployment(deployment_id);
913 let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
914 env.revisions[0].lifecycle = RevisionLifecycle::Ready;
915 env.traffic_splits.push(TrafficSplit {
916 schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
917 env_id: env_id(),
918 deployment_id,
919 bundle_id: BundleId::new("fast2flow"),
920 generation: 0,
921 entries: vec![TrafficSplitEntry {
922 revision_id: rev.revision_id,
923 weight_bps: 10_000,
924 }],
925 updated_at: fixed_now(),
926 updated_by: "tester".to_string(),
927 idempotency_key: "k1".to_string(),
928 authorization_ref: PathBuf::from("auth.json"),
929 previous_split_ref: None,
930 });
931
932 let err = archive_revision(&mut env, rev.revision_id).unwrap_err();
933 let RevisionLifecycleError::ActiveTrafficReference { splits, .. } = err else {
934 panic!("expected ActiveTrafficReference, got {err:?}");
935 };
936 assert_eq!(splits.len(), 1);
937 assert_eq!(splits[0].weight_bps, 10_000);
938 }
939
940 #[test]
941 fn unknown_revision_is_not_found() {
942 let mut env = env_with_deployment(DeploymentId::new());
943 let missing = RevisionId::new();
944 let err = drain_revision(&mut env, missing).unwrap_err();
945 assert_eq!(
946 err,
947 RevisionLifecycleError::NotFound {
948 env_id: env_id(),
949 revision_id: missing,
950 }
951 );
952 }
953
954 #[test]
957 fn stage_payload_wire_format_is_pinned() {
958 let deployment_id = DeploymentId::new();
959 let payload = stage_payload(deployment_id);
960 let value = serde_json::to_value(&payload).unwrap();
961 assert_eq!(
962 value,
963 json!({
964 "revision_id": payload.revision_id.to_string(),
965 "deployment_id": deployment_id.to_string(),
966 "bundle_digest": "sha256:00",
967 "pack_list": [{
968 "pack_id": "greentic.test.pack",
969 "version": "1.0.0",
970 "digest": "sha256:00",
971 }],
972 "pack_list_lock_ref": "pack-list.lock",
973 "pack_config_refs": [],
974 "config_digest": "sha256:00",
975 "signature_sidecar_ref": "rev.sig",
976 "drain_seconds": 30,
977 })
978 );
979 let back: StageRevisionPayload = serde_json::from_value(value).unwrap();
980 assert_eq!(back.revision_id, payload.revision_id);
981 }
982
983 #[test]
984 fn warm_payload_wire_format_is_pinned() {
985 let revision_id = RevisionId::new();
986 let ok = warm_payload(revision_id);
987 assert_eq!(
988 serde_json::to_value(&ok).unwrap(),
989 json!({
990 "revision_id": revision_id.to_string(),
991 "health_gate": {"ok": true},
992 "expected_lifecycle": "staged",
993 })
994 );
995
996 let mut failing = warm_payload(revision_id);
997 failing.health_gate = Err(HealthGateFailure {
998 failed_checks: vec![HealthCheckId::RouteTable, HealthCheckId::ProviderHealth],
999 message: "boom".to_string(),
1000 });
1001 let value = serde_json::to_value(&failing).unwrap();
1002 assert_eq!(
1003 value["health_gate"],
1004 json!({
1005 "ok": false,
1006 "failure": {
1007 "failed_checks": ["route-table", "provider-health"],
1008 "message": "boom",
1009 },
1010 })
1011 );
1012 let back: WarmRevisionPayload = serde_json::from_value(value).unwrap();
1013 assert_eq!(back.health_gate, failing.health_gate);
1014 }
1015
1016 #[test]
1017 fn warm_payload_rejects_contradictory_health_gate() {
1018 let err = serde_json::from_value::<WarmRevisionPayload>(json!({
1019 "revision_id": RevisionId::new().to_string(),
1020 "health_gate": {"ok": true, "failure": {"failed_checks": [], "message": "x"}},
1021 "expected_lifecycle": "staged",
1022 }))
1023 .unwrap_err();
1024 assert!(err.to_string().contains("cannot be `ok: true`"), "{err}");
1025
1026 let err = serde_json::from_value::<WarmRevisionPayload>(json!({
1027 "revision_id": RevisionId::new().to_string(),
1028 "health_gate": {"ok": false},
1029 "expected_lifecycle": "staged",
1030 }))
1031 .unwrap_err();
1032 assert!(err.to_string().contains("must carry a `failure`"), "{err}");
1033 }
1034
1035 #[test]
1036 fn revision_transition_outcome_round_trips() {
1037 let deployment_id = DeploymentId::new();
1038 let mut env = env_with_deployment(deployment_id);
1039 let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
1040 let outcome = RevisionTransitionOutcome {
1041 revision: rev,
1042 environment: env,
1043 starting_lifecycle: RevisionLifecycle::Staged,
1044 };
1045 let value = serde_json::to_value(&outcome).unwrap();
1046 assert_eq!(value["starting_lifecycle"], "staged");
1047 let back: RevisionTransitionOutcome = serde_json::from_value(value).unwrap();
1048 assert_eq!(back.revision.revision_id, outcome.revision.revision_id);
1049 assert_eq!(back.starting_lifecycle, RevisionLifecycle::Staged);
1050 }
1051}