1use chrono::{DateTime, Utc};
47use serde::{Deserialize, Serialize};
48use std::path::PathBuf;
49use thiserror::Error;
50
51use crate::engine::inline_stash;
52use crate::environment::Environment;
53use crate::error::SpecError;
54use crate::ids::{DeploymentId, RevisionId};
55use crate::remote::IdempotencyKey;
56use crate::revision::RevisionLifecycle;
57use crate::traffic_split::{TrafficSplit, TrafficSplitEntry};
58use crate::version::SchemaVersion;
59use greentic_types::EnvId;
60
61#[derive(Debug, PartialEq, Eq, Error)]
73pub enum TrafficSplitError {
74 #[error("deployment `{deployment_id}` not found in env `{env_id}`")]
76 DeploymentNotFound {
77 env_id: EnvId,
78 deployment_id: DeploymentId,
79 },
80 #[error("revision `{revision_id}` not found in env `{env_id}`")]
83 RevisionNotFound {
84 env_id: EnvId,
85 revision_id: RevisionId,
86 },
87 #[error("revision `{revision_id}` belongs to deployment `{actual}`, not `{expected}`")]
89 WrongDeployment {
90 revision_id: RevisionId,
91 actual: DeploymentId,
92 expected: DeploymentId,
93 },
94 #[error(
97 "idempotency key `{key}` already used for deployment `{deployment_id}` with different entries"
98 )]
99 IdempotencyKeyReused {
100 key: String,
101 deployment_id: DeploymentId,
102 },
103 #[error("revision `{revision_id}` not found in env `{env_id}`")]
109 AdmissionRevisionMissing {
110 env_id: EnvId,
111 revision_id: RevisionId,
112 },
113 #[error(
115 "revision `{revision_id}` is `{lifecycle:?}`; only `Ready` revisions may receive traffic"
116 )]
117 NotReady {
118 revision_id: RevisionId,
119 lifecycle: RevisionLifecycle,
120 },
121 #[error("snapshot prior split: {detail}")]
123 SnapshotEncode { detail: String },
124 #[error("no traffic split for deployment `{deployment_id}` in env `{env_id}`")]
126 NoSplit {
127 env_id: EnvId,
128 deployment_id: DeploymentId,
129 },
130 #[error("traffic split for `{deployment_id}` has no prior version to roll back to")]
132 NoPreviousSnapshot { deployment_id: DeploymentId },
133 #[error("previous split payload `{}` missing", prev_ref.display())]
135 SnapshotMissing { prev_ref: PathBuf },
136 #[error("deserialise previous split: {detail}")]
138 SnapshotDecode { detail: String },
139 #[error(transparent)]
142 Invalid(#[from] SpecError),
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SetTrafficSplitPayload {
156 pub deployment_id: DeploymentId,
157 pub entries: Vec<TrafficSplitEntry>,
158 pub updated_by: String,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub authorization_ref: Option<String>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct RollbackTrafficSplitPayload {
169 pub deployment_id: DeploymentId,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct ApplyTrafficSplitOutcome {
183 pub split: TrafficSplit,
184 pub previous_generation: Option<u64>,
185 pub new_generation: Option<u64>,
186 pub environment: Environment,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct RollbackTrafficSplitOutcome {
194 pub restored: TrafficSplit,
195 pub previous_generation: u64,
196 pub new_generation: u64,
197 pub environment: Environment,
198}
199
200#[derive(Debug, Clone)]
205pub struct TrafficSplitTransition {
206 pub split: TrafficSplit,
207 pub previous_generation: Option<u64>,
208 pub new_generation: Option<u64>,
209}
210
211impl TrafficSplitTransition {
212 pub fn mutated(&self) -> bool {
215 self.new_generation.is_some()
216 }
217}
218
219#[derive(Debug, Clone)]
221pub struct TrafficRollbackTransition {
222 pub restored: TrafficSplit,
223 pub previous_generation: u64,
224 pub new_generation: u64,
225}
226
227pub fn set_traffic_split(
242 env: &mut Environment,
243 payload: SetTrafficSplitPayload,
244 idempotency_key: &IdempotencyKey,
245 now: DateTime<Utc>,
246) -> Result<TrafficSplitTransition, TrafficSplitError> {
247 let SetTrafficSplitPayload {
248 deployment_id,
249 entries,
250 updated_by,
251 authorization_ref,
252 } = payload;
253 let env_id = env.environment_id.clone();
254 let deployment = env
255 .bundles
256 .iter()
257 .find(|b| b.deployment_id == deployment_id)
258 .ok_or_else(|| TrafficSplitError::DeploymentNotFound {
259 env_id: env_id.clone(),
260 deployment_id,
261 })?;
262 let bundle_id = deployment.bundle_id.clone();
263 for entry in &entries {
265 let rev = env
266 .revisions
267 .iter()
268 .find(|r| r.revision_id == entry.revision_id)
269 .ok_or_else(|| TrafficSplitError::RevisionNotFound {
270 env_id: env_id.clone(),
271 revision_id: entry.revision_id,
272 })?;
273 if rev.deployment_id != deployment_id {
274 return Err(TrafficSplitError::WrongDeployment {
275 revision_id: entry.revision_id,
276 actual: rev.deployment_id,
277 expected: deployment_id,
278 });
279 }
280 }
281 let prev_split_idx = env
285 .traffic_splits
286 .iter()
287 .position(|s| s.deployment_id == deployment_id);
288 if let Some(idx) = prev_split_idx {
289 let prev = &env.traffic_splits[idx];
290 if prev.idempotency_key == idempotency_key.as_str() {
291 if entries_match(&prev.entries, &entries) {
292 return Ok(TrafficSplitTransition {
293 split: prev.clone(),
294 previous_generation: None,
295 new_generation: None,
296 });
297 }
298 return Err(TrafficSplitError::IdempotencyKeyReused {
299 key: idempotency_key.as_str().to_string(),
300 deployment_id,
301 });
302 }
303 }
304 assert_entries_all_ready(env, &entries)?;
308 let (generation, previous_split_ref, prev_gen) = match prev_split_idx {
309 Some(idx) => {
310 let mut stash_copy = env.traffic_splits[idx].clone();
315 let prev_generation = stash_copy.generation;
316 stash_copy.previous_split_ref = None;
317 let snapshot = serde_json::to_value(&stash_copy).map_err(|e| {
318 TrafficSplitError::SnapshotEncode {
319 detail: e.to_string(),
320 }
321 })?;
322 (
323 prev_generation + 1,
324 Some(inline_stash::stash_inline(snapshot)),
325 Some(prev_generation),
326 )
327 }
328 None => (0, None, None),
329 };
330 let split = TrafficSplit {
331 schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
332 env_id,
333 deployment_id,
334 bundle_id,
335 generation,
336 entries,
337 updated_at: now,
338 updated_by,
339 idempotency_key: idempotency_key.as_str().to_string(),
340 authorization_ref: authorization_ref
341 .map(PathBuf::from)
342 .unwrap_or_else(|| PathBuf::from("auth.json")),
343 previous_split_ref,
344 };
345 split.validate()?;
346 match prev_split_idx {
347 Some(idx) => env.traffic_splits[idx] = split.clone(),
348 None => env.traffic_splits.push(split.clone()),
349 }
350 Ok(TrafficSplitTransition {
351 split,
352 previous_generation: prev_gen,
353 new_generation: Some(generation),
354 })
355}
356
357pub fn rollback_traffic_split(
362 env: &mut Environment,
363 deployment_id: DeploymentId,
364 now: DateTime<Utc>,
365) -> Result<TrafficRollbackTransition, TrafficSplitError> {
366 let idx = env
367 .traffic_splits
368 .iter()
369 .position(|s| s.deployment_id == deployment_id)
370 .ok_or_else(|| TrafficSplitError::NoSplit {
371 env_id: env.environment_id.clone(),
372 deployment_id,
373 })?;
374 let prev_split_generation = env.traffic_splits[idx].generation;
375 let prev_ref = env.traffic_splits[idx]
376 .previous_split_ref
377 .clone()
378 .ok_or(TrafficSplitError::NoPreviousSnapshot { deployment_id })?;
379 let prev_value = inline_stash::load_inline(&prev_ref)
380 .ok_or(TrafficSplitError::SnapshotMissing { prev_ref })?;
381 let mut restored: TrafficSplit =
382 serde_json::from_value(prev_value).map_err(|e| TrafficSplitError::SnapshotDecode {
383 detail: e.to_string(),
384 })?;
385 let new_generation = prev_split_generation + 1;
386 restored.generation = new_generation;
387 restored.previous_split_ref = None;
388 restored.updated_at = now;
389 restored.idempotency_key = format!("rollback-{}", env.traffic_splits[idx].idempotency_key);
390 restored.validate()?;
391 assert_entries_all_ready(env, &restored.entries)?;
392 env.traffic_splits[idx] = restored.clone();
393 Ok(TrafficRollbackTransition {
394 restored,
395 previous_generation: prev_split_generation,
396 new_generation,
397 })
398}
399
400pub fn assert_entries_all_ready(
404 env: &Environment,
405 entries: &[TrafficSplitEntry],
406) -> Result<(), TrafficSplitError> {
407 for entry in entries {
408 let rev = env
409 .revisions
410 .iter()
411 .find(|r| r.revision_id == entry.revision_id)
412 .ok_or_else(|| TrafficSplitError::AdmissionRevisionMissing {
413 env_id: env.environment_id.clone(),
414 revision_id: entry.revision_id,
415 })?;
416 if rev.lifecycle != RevisionLifecycle::Ready {
417 return Err(TrafficSplitError::NotReady {
418 revision_id: entry.revision_id,
419 lifecycle: rev.lifecycle,
420 });
421 }
422 }
423 Ok(())
424}
425
426pub fn entries_match(a: &[TrafficSplitEntry], b: &[TrafficSplitEntry]) -> bool {
430 if a.len() != b.len() {
431 return false;
432 }
433 let mut a_sorted: Vec<(&RevisionId, u32)> =
434 a.iter().map(|e| (&e.revision_id, e.weight_bps)).collect();
435 let mut b_sorted: Vec<(&RevisionId, u32)> =
436 b.iter().map(|e| (&e.revision_id, e.weight_bps)).collect();
437 a_sorted.sort_by_key(|(r, _)| r.to_string());
438 b_sorted.sort_by_key(|(r, _)| r.to_string());
439 a_sorted == b_sorted
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::bundle_deployment::{
446 BundleDeployment, BundleDeploymentStatus, RevenueShareEntry, RouteBinding, TenantSelector,
447 };
448 use crate::engine::{StageRevisionPayload, stage_revision};
449 use crate::ids::{BundleId, CustomerId, PackId, PartyId};
450 use crate::revision::PackListEntry;
451 use crate::version::SemVer;
452 use chrono::TimeZone;
453 use serde_json::json;
454 use std::collections::BTreeMap;
455
456 fn env_id() -> EnvId {
457 EnvId::try_from("local").unwrap()
458 }
459
460 fn fixed_now() -> DateTime<Utc> {
461 Utc.with_ymd_and_hms(2026, 6, 12, 12, 0, 0).unwrap()
462 }
463
464 fn idem(raw: &str) -> IdempotencyKey {
465 IdempotencyKey::new(raw).expect("valid idempotency key")
466 }
467
468 fn deployment(deployment_id: DeploymentId) -> BundleDeployment {
469 BundleDeployment {
470 schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
471 deployment_id,
472 env_id: env_id(),
473 bundle_id: BundleId::new("fast2flow"),
474 customer_id: CustomerId::new("local-dev"),
475 status: BundleDeploymentStatus::Active,
476 current_revisions: Vec::new(),
477 route_binding: RouteBinding {
478 hosts: vec!["fast2flow.local".to_string()],
479 path_prefixes: Vec::new(),
480 tenant_selector: TenantSelector {
481 tenant: "default".to_string(),
482 team: "default".to_string(),
483 },
484 },
485 revenue_share: vec![RevenueShareEntry {
486 party_id: PartyId::new("greentic"),
487 basis_points: 10_000,
488 }],
489 revenue_policy_ref: PathBuf::from("revenue.json"),
490 usage: None,
491 created_at: fixed_now(),
492 authorization_ref: PathBuf::from("auth.json"),
493 config_overrides: BTreeMap::new(),
494 }
495 }
496
497 fn stage_payload(deployment_id: DeploymentId) -> StageRevisionPayload {
498 StageRevisionPayload {
499 revision_id: RevisionId::new(),
500 deployment_id,
501 bundle_digest: "sha256:00".to_string(),
502 bundle_source_uri: None,
503 pack_list: vec![PackListEntry {
504 pack_id: PackId::new("greentic.test.pack"),
505 version: SemVer::new(1, 0, 0),
506 digest: "sha256:00".to_string(),
507 source_uri: None,
508 }],
509 pack_list_lock_ref: PathBuf::from("pack-list.lock"),
510 pack_config_refs: Vec::new(),
511 config_digest: "sha256:00".to_string(),
512 signature_sidecar_ref: PathBuf::from("rev.sig"),
513 drain_seconds: 30,
514 }
515 }
516
517 fn env_with_ready_revisions(
519 deployment_id: DeploymentId,
520 ) -> (Environment, RevisionId, RevisionId) {
521 let mut env = super::super::fresh_environment(
522 &env_id(),
523 "local".to_string(),
524 crate::environment::EnvironmentHostConfig {
525 env_id: env_id(),
526 region: None,
527 tenant_org_id: None,
528 listen_addr: None,
529 public_base_url: None,
530 gui_enabled: None,
531 },
532 Default::default(),
533 Default::default(),
534 Default::default(),
535 );
536 env.bundles.push(deployment(deployment_id));
537 let r1 = stage_revision(&mut env, stage_payload(deployment_id), fixed_now())
538 .unwrap()
539 .revision_id;
540 let r2 = stage_revision(&mut env, stage_payload(deployment_id), fixed_now())
541 .unwrap()
542 .revision_id;
543 for rev in &mut env.revisions {
544 rev.lifecycle = RevisionLifecycle::Ready;
545 }
546 (env, r1, r2)
547 }
548
549 fn set_payload(
550 deployment_id: DeploymentId,
551 entries: Vec<TrafficSplitEntry>,
552 ) -> SetTrafficSplitPayload {
553 SetTrafficSplitPayload {
554 deployment_id,
555 entries,
556 updated_by: "operator@local".to_string(),
557 authorization_ref: None,
558 }
559 }
560
561 fn full_weight(revision_id: RevisionId) -> Vec<TrafficSplitEntry> {
562 vec![TrafficSplitEntry {
563 revision_id,
564 weight_bps: 10_000,
565 }]
566 }
567
568 #[test]
571 fn set_first_split_starts_at_generation_zero_without_stash() {
572 let deployment_id = DeploymentId::new();
573 let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
574
575 let t = set_traffic_split(
576 &mut env,
577 set_payload(deployment_id, full_weight(r1)),
578 &idem("k1"),
579 fixed_now(),
580 )
581 .unwrap();
582 assert!(t.mutated());
583 assert_eq!(t.split.generation, 0);
584 assert_eq!(t.previous_generation, None);
585 assert_eq!(t.new_generation, Some(0));
586 assert_eq!(t.split.previous_split_ref, None);
587 assert_eq!(t.split.idempotency_key, "k1");
588 assert_eq!(
589 t.split.authorization_ref,
590 PathBuf::from("auth.json"),
591 "absent authorization_ref defaults"
592 );
593 assert_eq!(env.traffic_splits.len(), 1);
594 }
595
596 #[test]
597 fn set_second_split_advances_generation_and_stashes_previous() {
598 let deployment_id = DeploymentId::new();
599 let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
600
601 set_traffic_split(
602 &mut env,
603 set_payload(deployment_id, full_weight(r1)),
604 &idem("k1"),
605 fixed_now(),
606 )
607 .unwrap();
608 let t = set_traffic_split(
609 &mut env,
610 set_payload(deployment_id, full_weight(r2)),
611 &idem("k2"),
612 fixed_now(),
613 )
614 .unwrap();
615 assert_eq!(t.previous_generation, Some(0));
616 assert_eq!(t.new_generation, Some(1));
617 let stash = t.split.previous_split_ref.as_ref().expect("stash present");
618 let prev: TrafficSplit =
619 serde_json::from_value(inline_stash::load_inline(stash).expect("stash decodes"))
620 .expect("stash is a TrafficSplit");
621 assert_eq!(prev.generation, 0);
622 assert_eq!(prev.entries, full_weight(r1));
623 assert_eq!(env.traffic_splits.len(), 1, "replace, not append");
624 }
625
626 #[test]
629 fn set_same_key_same_entries_is_a_no_op_replay() {
630 let deployment_id = DeploymentId::new();
631 let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
632
633 let entries = vec![
634 TrafficSplitEntry {
635 revision_id: r1,
636 weight_bps: 4_000,
637 },
638 TrafficSplitEntry {
639 revision_id: r2,
640 weight_bps: 6_000,
641 },
642 ];
643 set_traffic_split(
644 &mut env,
645 set_payload(deployment_id, entries.clone()),
646 &idem("k1"),
647 fixed_now(),
648 )
649 .unwrap();
650 let snapshot = env.clone();
651
652 let permuted: Vec<TrafficSplitEntry> = entries.into_iter().rev().collect();
655 let t = set_traffic_split(
656 &mut env,
657 set_payload(deployment_id, permuted),
658 &idem("k1"),
659 fixed_now(),
660 )
661 .unwrap();
662 assert!(!t.mutated());
663 assert_eq!(t.previous_generation, None);
664 assert_eq!(t.new_generation, None);
665 assert_eq!(t.split.generation, 0, "replay returns the live split");
666 assert_eq!(env, snapshot, "no-op replay must not touch the env");
667 }
668
669 #[test]
670 fn set_same_key_different_entries_is_a_key_reuse_conflict() {
671 let deployment_id = DeploymentId::new();
672 let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
673
674 set_traffic_split(
675 &mut env,
676 set_payload(deployment_id, full_weight(r1)),
677 &idem("k1"),
678 fixed_now(),
679 )
680 .unwrap();
681 let err = set_traffic_split(
682 &mut env,
683 set_payload(deployment_id, full_weight(r2)),
684 &idem("k1"),
685 fixed_now(),
686 )
687 .unwrap_err();
688 assert!(
689 matches!(&err, TrafficSplitError::IdempotencyKeyReused { key, .. } if key == "k1"),
690 "unexpected error: {err:?}"
691 );
692 }
693
694 #[test]
697 fn set_rejects_unknown_deployment_and_unknown_revision() {
698 let deployment_id = DeploymentId::new();
699 let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
700
701 let err = set_traffic_split(
702 &mut env,
703 set_payload(DeploymentId::new(), full_weight(r1)),
704 &idem("k1"),
705 fixed_now(),
706 )
707 .unwrap_err();
708 assert!(matches!(err, TrafficSplitError::DeploymentNotFound { .. }));
709
710 let err = set_traffic_split(
711 &mut env,
712 set_payload(deployment_id, full_weight(RevisionId::new())),
713 &idem("k1"),
714 fixed_now(),
715 )
716 .unwrap_err();
717 assert!(matches!(err, TrafficSplitError::RevisionNotFound { .. }));
718 assert!(env.traffic_splits.is_empty());
719 }
720
721 #[test]
722 fn set_rejects_revision_owned_by_another_deployment() {
723 let deployment_id = DeploymentId::new();
724 let (mut env, _, _) = env_with_ready_revisions(deployment_id);
725 let other_deployment = DeploymentId::new();
726 env.bundles.push(deployment(other_deployment));
727 let foreign = stage_revision(&mut env, stage_payload(other_deployment), fixed_now())
728 .unwrap()
729 .revision_id;
730 env.revisions.last_mut().unwrap().lifecycle = RevisionLifecycle::Ready;
731
732 let err = set_traffic_split(
733 &mut env,
734 set_payload(deployment_id, full_weight(foreign)),
735 &idem("k1"),
736 fixed_now(),
737 )
738 .unwrap_err();
739 assert!(
740 matches!(
741 &err,
742 TrafficSplitError::WrongDeployment { revision_id, actual, expected }
743 if *revision_id == foreign
744 && *actual == other_deployment
745 && *expected == deployment_id
746 ),
747 "unexpected error: {err:?}"
748 );
749 }
750
751 #[test]
752 fn set_rejects_non_ready_revision_per_admission_gate() {
753 let deployment_id = DeploymentId::new();
754 let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
755 env.revisions[0].lifecycle = RevisionLifecycle::Staged;
756
757 let err = set_traffic_split(
758 &mut env,
759 set_payload(deployment_id, full_weight(r1)),
760 &idem("k1"),
761 fixed_now(),
762 )
763 .unwrap_err();
764 assert!(
765 matches!(
766 &err,
767 TrafficSplitError::NotReady { lifecycle, .. }
768 if *lifecycle == RevisionLifecycle::Staged
769 ),
770 "unexpected error: {err:?}"
771 );
772 }
773
774 #[test]
775 fn set_rejects_weights_not_summing_to_ten_thousand() {
776 let deployment_id = DeploymentId::new();
777 let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
778
779 let err = set_traffic_split(
780 &mut env,
781 set_payload(
782 deployment_id,
783 vec![TrafficSplitEntry {
784 revision_id: r1,
785 weight_bps: 9_999,
786 }],
787 ),
788 &idem("k1"),
789 fixed_now(),
790 )
791 .unwrap_err();
792 assert!(
793 matches!(err, TrafficSplitError::Invalid(_)),
794 "unexpected error: {err:?}"
795 );
796 assert!(env.traffic_splits.is_empty(), "invalid split not applied");
797 }
798
799 #[test]
802 fn rollback_restores_previous_entries_and_advances_generation() {
803 let deployment_id = DeploymentId::new();
804 let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
805
806 set_traffic_split(
807 &mut env,
808 set_payload(deployment_id, full_weight(r1)),
809 &idem("k1"),
810 fixed_now(),
811 )
812 .unwrap();
813 set_traffic_split(
814 &mut env,
815 set_payload(deployment_id, full_weight(r2)),
816 &idem("k2"),
817 fixed_now(),
818 )
819 .unwrap();
820
821 let t = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap();
822 assert_eq!(t.previous_generation, 1);
823 assert_eq!(t.new_generation, 2, "rollback advances, never rewinds");
824 assert_eq!(t.restored.entries, full_weight(r1));
825 assert_eq!(t.restored.idempotency_key, "rollback-k2");
826 assert_eq!(
827 t.restored.previous_split_ref, None,
828 "one-step rollback only"
829 );
830 assert_eq!(env.traffic_splits[0], t.restored);
831
832 let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
835 assert!(matches!(err, TrafficSplitError::NoPreviousSnapshot { .. }));
836 }
837
838 #[test]
839 fn rollback_without_split_is_no_split() {
840 let deployment_id = DeploymentId::new();
841 let (mut env, _, _) = env_with_ready_revisions(deployment_id);
842 let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
843 assert!(matches!(err, TrafficSplitError::NoSplit { .. }));
844 }
845
846 #[test]
847 fn rollback_re_checks_admission_on_restored_entries() {
848 let deployment_id = DeploymentId::new();
849 let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
850
851 set_traffic_split(
852 &mut env,
853 set_payload(deployment_id, full_weight(r1)),
854 &idem("k1"),
855 fixed_now(),
856 )
857 .unwrap();
858 set_traffic_split(
859 &mut env,
860 set_payload(deployment_id, full_weight(r2)),
861 &idem("k2"),
862 fixed_now(),
863 )
864 .unwrap();
865 env.revisions[0].lifecycle = RevisionLifecycle::Archived;
867 let snapshot = env.clone();
868
869 let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
870 assert!(
871 matches!(&err, TrafficSplitError::NotReady { revision_id, .. } if *revision_id == r1),
872 "unexpected error: {err:?}"
873 );
874 assert_eq!(env, snapshot, "failed rollback must not mutate the env");
875 }
876
877 #[test]
880 fn entries_match_is_order_insensitive_and_weight_sensitive() {
881 let (r1, r2) = (RevisionId::new(), RevisionId::new());
882 let a = vec![
883 TrafficSplitEntry {
884 revision_id: r1,
885 weight_bps: 4_000,
886 },
887 TrafficSplitEntry {
888 revision_id: r2,
889 weight_bps: 6_000,
890 },
891 ];
892 let permuted: Vec<TrafficSplitEntry> = a.iter().rev().cloned().collect();
893 assert!(entries_match(&a, &permuted));
894 let reweighted = vec![
895 TrafficSplitEntry {
896 revision_id: r1,
897 weight_bps: 5_000,
898 },
899 TrafficSplitEntry {
900 revision_id: r2,
901 weight_bps: 5_000,
902 },
903 ];
904 assert!(!entries_match(&a, &reweighted));
905 assert!(!entries_match(&a, &a[..1]));
906 }
907
908 #[test]
911 fn set_payload_wire_format_is_pinned() {
912 let deployment_id = DeploymentId::new();
913 let revision_id = RevisionId::new();
914 let payload = SetTrafficSplitPayload {
915 deployment_id,
916 entries: vec![TrafficSplitEntry {
917 revision_id,
918 weight_bps: 10_000,
919 }],
920 updated_by: "operator@local".to_string(),
921 authorization_ref: None,
922 };
923 assert_eq!(
924 serde_json::to_value(&payload).unwrap(),
925 json!({
926 "deployment_id": deployment_id.to_string(),
927 "entries": [{"revision_id": revision_id.to_string(), "weight_bps": 10_000}],
928 "updated_by": "operator@local",
929 }),
930 "authorization_ref must be ABSENT when None (client encoding)"
931 );
932
933 let rollback = RollbackTrafficSplitPayload { deployment_id };
934 assert_eq!(
935 serde_json::to_value(&rollback).unwrap(),
936 json!({"deployment_id": deployment_id.to_string()})
937 );
938 }
939
940 #[test]
941 fn apply_outcome_round_trips_with_environment_snapshot() {
942 let deployment_id = DeploymentId::new();
943 let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
944 let t = set_traffic_split(
945 &mut env,
946 set_payload(deployment_id, full_weight(r1)),
947 &idem("k1"),
948 fixed_now(),
949 )
950 .unwrap();
951 let outcome = ApplyTrafficSplitOutcome {
952 split: t.split,
953 previous_generation: t.previous_generation,
954 new_generation: t.new_generation,
955 environment: env,
956 };
957 let value = serde_json::to_value(&outcome).unwrap();
958 assert_eq!(value["new_generation"], 0);
959 assert_eq!(value["previous_generation"], serde_json::Value::Null);
960 assert_eq!(value["environment"]["environment_id"], "local");
961 let back: ApplyTrafficSplitOutcome = serde_json::from_value(value).unwrap();
962 assert_eq!(back.split, outcome.split);
963 assert_eq!(back.environment, outcome.environment);
964 }
965
966 #[test]
967 fn stash_is_bounded_to_one_level_across_multiple_sets() {
968 let deployment_id = DeploymentId::new();
969 let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
970
971 set_traffic_split(
973 &mut env,
974 set_payload(deployment_id, full_weight(r1)),
975 &idem("k1"),
976 fixed_now(),
977 )
978 .unwrap();
979 let t2 = set_traffic_split(
980 &mut env,
981 set_payload(deployment_id, full_weight(r2)),
982 &idem("k2"),
983 fixed_now(),
984 )
985 .unwrap();
986 let t3 = set_traffic_split(
987 &mut env,
988 set_payload(deployment_id, full_weight(r1)),
989 &idem("k3"),
990 fixed_now(),
991 )
992 .unwrap();
993
994 let stash3 = t3
997 .split
998 .previous_split_ref
999 .as_ref()
1000 .expect("stash present after set #3");
1001 let prev3: TrafficSplit =
1002 serde_json::from_value(inline_stash::load_inline(stash3).expect("stash decodes"))
1003 .expect("stash is a TrafficSplit");
1004 assert_eq!(
1005 prev3.previous_split_ref, None,
1006 "stash must be one level deep"
1007 );
1008 assert_eq!(prev3.generation, 1);
1009 assert_eq!(prev3.entries, full_weight(r2));
1010
1011 let stash2 = t2
1013 .split
1014 .previous_split_ref
1015 .as_ref()
1016 .expect("stash present after set #2");
1017 let prev2: TrafficSplit =
1018 serde_json::from_value(inline_stash::load_inline(stash2).expect("stash decodes"))
1019 .expect("stash is a TrafficSplit");
1020 assert_eq!(
1021 prev2.previous_split_ref, None,
1022 "stash must be one level deep"
1023 );
1024
1025 let len3 = stash3.as_os_str().len();
1028 let len2 = stash2.as_os_str().len();
1029 assert!(
1030 len3 <= len2 + 64,
1031 "stash tokens must not grow unboundedly: len(#3)={len3} vs len(#2)={len2}",
1032 );
1033 }
1034}