1use std::path::PathBuf;
18
19use greentic_deploy_spec::{DeploymentId, EnvId, RevisionId, TrafficSplit, TrafficSplitEntry};
20use serde::{Deserialize, Serialize};
21use serde_json::{Value, json};
22
23use crate::environment::{EnvironmentReads, EnvironmentStore, LocalFsStore};
24
25use super::dispatch::{TrafficSetArgs, TrafficTargetArgs};
26use super::{
27 AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
28 resolve_idempotency_key,
29};
30
31const NOUN: &str = "traffic";
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TrafficSetPayload {
35 pub environment_id: String,
36 pub deployment_id: String,
37 pub entries: Vec<TrafficSetEntryPayload>,
38 #[serde(default = "default_updated_by")]
39 pub updated_by: String,
40 pub idempotency_key: String,
41 #[serde(default = "default_authorization_ref")]
42 pub authorization_ref: PathBuf,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct TrafficSetEntryPayload {
47 pub revision_id: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub weight_bps: Option<u32>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub weight_percent: Option<u32>,
55}
56
57pub(super) fn default_updated_by() -> String {
58 "operator".to_string()
59}
60pub(super) fn default_authorization_ref() -> PathBuf {
61 PathBuf::from("auth.json")
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TrafficSummary {
66 pub environment_id: String,
67 pub deployment_id: String,
68 pub bundle_id: String,
69 pub generation: u64,
70 pub entries: Vec<TrafficSummaryEntry>,
71 pub has_previous: bool,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct TrafficSummaryEntry {
76 pub revision_id: String,
77 pub weight_bps: u32,
78}
79
80impl TrafficSummary {
81 pub(crate) fn from(env_id: &EnvId, split: &TrafficSplit) -> Self {
82 Self {
83 environment_id: env_id.as_str().to_string(),
84 deployment_id: split.deployment_id.to_string(),
85 bundle_id: split.bundle_id.as_str().to_string(),
86 generation: split.generation,
87 entries: split
88 .entries
89 .iter()
90 .map(|e| TrafficSummaryEntry {
91 revision_id: e.revision_id.to_string(),
92 weight_bps: e.weight_bps,
93 })
94 .collect(),
95 has_previous: split.previous_split_ref.is_some(),
96 }
97 }
98}
99
100pub fn set(
104 store: &LocalFsStore,
105 flags: &OpFlags,
106 payload: Option<TrafficSetPayload>,
107) -> Result<OpOutcome, OpError> {
108 if flags.schema_only {
109 return Ok(OpOutcome::new(NOUN, "set", set_schema()));
110 }
111 let payload = resolve_payload::<TrafficSetPayload>(flags, payload)?;
112 let env_id = parse_env_id(&payload.environment_id)?;
113 let deployment_id = parse_deployment_id(&payload.deployment_id)?;
114 let parsed_entries = parse_entries(&payload.entries)?;
118 {
122 let env = store.load(&env_id)?;
123 for entry in &parsed_entries {
124 let rev = env
125 .revisions
126 .iter()
127 .find(|r| r.revision_id == entry.revision_id)
128 .ok_or_else(|| {
129 OpError::NotFound(format!(
130 "revision `{}` not found in env `{env_id}`",
131 entry.revision_id
132 ))
133 })?;
134 if rev.deployment_id != deployment_id {
135 return Err(OpError::InvalidArgument(format!(
136 "revision `{}` belongs to deployment `{}`, not `{}`",
137 entry.revision_id, rev.deployment_id, deployment_id,
138 )));
139 }
140 }
141 }
142 let idempotency_key = greentic_deploy_spec::IdempotencyKey::new(payload.idempotency_key)
143 .map_err(|e| OpError::InvalidArgument(format!("idempotency_key: {e}")))?;
144 let ctx = AuditCtx {
145 env_id: env_id.clone(),
146 noun: NOUN,
147 verb: "set",
148 target: json!({"deployment_id": deployment_id.to_string()}),
149 idempotency_key: Some(idempotency_key.as_str().to_string()),
150 };
151 audit_and_record(store, ctx, |committed| {
152 let outcome = store
153 .set_traffic_split(
154 &env_id,
155 greentic_deploy_spec::SetTrafficSplitPayload {
156 deployment_id,
157 entries: parsed_entries,
158 updated_by: payload.updated_by,
159 authorization_ref: Some(
160 payload.authorization_ref.to_string_lossy().into_owned(),
161 ),
162 },
163 idempotency_key,
164 )
165 .inspect_err(|err| {
166 if err.is_committed_after_save() {
167 committed.mark_committed();
168 }
169 })
170 .map_err(map_traffic_store_err)?;
171 emit_applied_telemetry(&outcome);
172 let gens = super::AuditGens {
173 previous: outcome.previous_generation,
174 new: outcome.new_generation,
175 };
176 let op_outcome = OpOutcome::new(
177 NOUN,
178 "set",
179 serde_json::to_value(TrafficSummary::from(&env_id, &outcome.split))
180 .expect("TrafficSummary is json-safe"),
181 );
182 Ok((op_outcome, gens))
183 })
184}
185
186pub(crate) fn emit_applied_telemetry(outcome: &crate::environment::ApplyTrafficSplitOutcome) {
192 if outcome.new_generation.is_some() {
193 crate::rollout_telemetry::emit_traffic_split_applied(
194 &outcome.environment,
195 outcome.split.deployment_id,
196 &outcome.split.bundle_id,
197 outcome.split.generation,
198 );
199 }
200}
201
202pub(crate) fn emit_rollback_telemetry(outcome: &crate::environment::RollbackTrafficSplitOutcome) {
206 crate::rollout_telemetry::emit_traffic_split_applied(
207 &outcome.environment,
208 outcome.restored.deployment_id,
209 &outcome.restored.bundle_id,
210 outcome.restored.generation,
211 );
212}
213
214pub fn payload_from_set_args(args: TrafficSetArgs) -> Result<Option<TrafficSetPayload>, OpError> {
223 let TrafficSetArgs {
224 env_id,
225 entries,
226 deployment,
227 idempotency_key,
228 updated_by,
229 authorization_ref,
230 } = args;
231 if env_id.is_none() && deployment.is_none() && entries.is_empty() {
233 return Ok(None);
234 }
235 let environment_id = env_id.ok_or_else(|| {
236 OpError::InvalidArgument("traffic set: missing positional `<env_id>`".to_string())
237 })?;
238 let deployment_id = deployment.ok_or_else(|| {
239 OpError::InvalidArgument("traffic set: missing `--deployment <ULID>`".to_string())
240 })?;
241 if entries.is_empty() {
242 return Err(OpError::InvalidArgument(
243 "traffic set: at least one `<revision_id>=<weight>` entry is required".to_string(),
244 ));
245 }
246 let idempotency_key = idempotency_key.ok_or_else(|| {
253 OpError::InvalidArgument(
254 "traffic set: missing `--idempotency-key <KEY>`. Pass any stable string \
255 (ULID, UUID, ticket id) — re-running the same command with the same key \
256 is a no-op replay; a different key (or omitting it) destroys the one-step \
257 rollback target."
258 .to_string(),
259 )
260 })?;
261 let entries = entries
262 .iter()
263 .map(|raw| parse_entry_arg(raw))
264 .collect::<Result<Vec<_>, _>>()?;
265 Ok(Some(TrafficSetPayload {
266 environment_id,
267 deployment_id,
268 entries,
269 updated_by: updated_by.unwrap_or_else(default_updated_by),
270 idempotency_key,
271 authorization_ref: authorization_ref.unwrap_or_else(default_authorization_ref),
272 }))
273}
274
275pub fn payload_from_target_args(
278 args: TrafficTargetArgs,
279) -> Result<Option<TrafficShowPayload>, OpError> {
280 let TrafficTargetArgs { env_id, deployment } = args;
281 if env_id.is_none() && deployment.is_none() {
282 return Ok(None);
283 }
284 let environment_id = env_id.ok_or_else(|| {
285 OpError::InvalidArgument("traffic: missing positional `<env_id>`".to_string())
286 })?;
287 let deployment_id = deployment.ok_or_else(|| {
288 OpError::InvalidArgument("traffic: missing `--deployment <ULID>`".to_string())
289 })?;
290 Ok(Some(TrafficShowPayload {
291 environment_id,
292 deployment_id,
293 idempotency_key: None,
294 }))
295}
296
297fn parse_entry_arg(raw: &str) -> Result<TrafficSetEntryPayload, OpError> {
303 let (rid, weight) = raw.split_once('=').ok_or_else(|| {
304 OpError::InvalidArgument(format!(
305 "entry `{raw}` must be `<revision_id>=<percent>` or `<revision_id>=<N>bps`"
306 ))
307 })?;
308 if rid.is_empty() {
309 return Err(OpError::InvalidArgument(format!(
310 "entry `{raw}` has an empty revision_id"
311 )));
312 }
313 let weight = weight.trim();
314 if weight.is_empty() {
315 return Err(OpError::InvalidArgument(format!(
316 "entry `{raw}` has an empty weight"
317 )));
318 }
319 let (weight_bps, weight_percent) = if let Some(rest) = weight.strip_suffix("bps") {
320 let bps: u32 = rest.trim().parse().map_err(|e| {
321 OpError::InvalidArgument(format!("entry `{raw}`: parse basis points: {e}"))
322 })?;
323 (Some(bps), None)
324 } else {
325 let pct_str = weight.strip_suffix('%').unwrap_or(weight).trim();
326 let pct: u32 = pct_str
327 .parse()
328 .map_err(|e| OpError::InvalidArgument(format!("entry `{raw}`: parse percent: {e}")))?;
329 (None, Some(pct))
330 };
331 Ok(TrafficSetEntryPayload {
332 revision_id: rid.to_string(),
333 weight_bps,
334 weight_percent,
335 })
336}
337
338pub(crate) fn parse_entries(
339 entries: &[TrafficSetEntryPayload],
340) -> Result<Vec<TrafficSplitEntry>, OpError> {
341 let mut out = Vec::with_capacity(entries.len());
342 for entry in entries {
343 let bps = match (entry.weight_bps, entry.weight_percent) {
344 (Some(bps), _) => bps,
345 (None, Some(pct)) => {
346 if pct > 100 {
347 return Err(OpError::InvalidArgument(format!(
348 "weight_percent {pct} > 100"
349 )));
350 }
351 pct.saturating_mul(100)
352 }
353 (None, None) => {
354 return Err(OpError::InvalidArgument(
355 "each entry must set weight_bps or weight_percent".to_string(),
356 ));
357 }
358 };
359 let revision_id = parse_revision_id(&entry.revision_id)?;
360 out.push(TrafficSplitEntry {
361 revision_id,
362 weight_bps: bps,
363 });
364 }
365 Ok(out)
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct TrafficShowPayload {
370 pub environment_id: String,
371 pub deployment_id: String,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub idempotency_key: Option<String>,
376}
377
378pub fn show(
379 store: &dyn EnvironmentReads,
380 flags: &OpFlags,
381 payload: Option<TrafficShowPayload>,
382) -> Result<OpOutcome, OpError> {
383 if flags.schema_only {
384 return Ok(OpOutcome::new(NOUN, "show", show_schema()));
385 }
386 let payload = resolve_payload::<TrafficShowPayload>(flags, payload)?;
387 let env_id = parse_env_id(&payload.environment_id)?;
388 let env = store.load_env(&env_id)?;
389 let deployment_id = parse_deployment_id(&payload.deployment_id)?;
390 let split = env
391 .traffic_splits
392 .iter()
393 .find(|s| s.deployment_id == deployment_id)
394 .ok_or_else(|| {
395 OpError::NotFound(format!(
396 "no traffic split for deployment `{deployment_id}` in env `{env_id}`"
397 ))
398 })?;
399 Ok(OpOutcome::new(
400 NOUN,
401 "show",
402 serde_json::to_value(TrafficSummary::from(&env_id, split))
403 .expect("TrafficSummary is json-safe"),
404 ))
405}
406
407pub fn rollback(
408 store: &LocalFsStore,
409 flags: &OpFlags,
410 payload: Option<TrafficShowPayload>,
411) -> Result<OpOutcome, OpError> {
412 if flags.schema_only {
413 return Ok(OpOutcome::new(NOUN, "rollback", show_schema()));
414 }
415 let payload = resolve_payload::<TrafficShowPayload>(flags, payload)?;
416 let env_id = parse_env_id(&payload.environment_id)?;
417 let deployment_id = parse_deployment_id(&payload.deployment_id)?;
418 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
419 let ctx = AuditCtx {
420 env_id: env_id.clone(),
421 noun: NOUN,
422 verb: "rollback",
423 target: json!({"deployment_id": deployment_id.to_string()}),
424 idempotency_key: Some(idempotency_key.as_str().to_string()),
425 };
426 audit_and_record(store, ctx, |committed| {
427 let outcome = store
428 .rollback_traffic_split(&env_id, deployment_id, idempotency_key)
429 .inspect_err(|err| {
430 if err.is_committed_after_save() {
431 committed.mark_committed();
432 }
433 })
434 .map_err(map_traffic_store_err)?;
435 emit_rollback_telemetry(&outcome);
436 let gens = super::AuditGens {
437 previous: Some(outcome.previous_generation),
438 new: Some(outcome.new_generation),
439 };
440 let op_outcome = OpOutcome::new(
441 NOUN,
442 "rollback",
443 serde_json::to_value(TrafficSummary::from(&env_id, &outcome.restored))
444 .expect("TrafficSummary is json-safe"),
445 );
446 Ok((op_outcome, gens))
447 })
448}
449
450pub(crate) fn map_traffic_store_err(e: crate::environment::StoreError) -> OpError {
458 match e {
459 crate::environment::StoreError::Spec(s) => OpError::Spec(s),
460 other => map_store_err_preserving_noun(other),
461 }
462}
463
464fn resolve_payload<T: serde::de::DeserializeOwned>(
465 flags: &OpFlags,
466 payload: Option<T>,
467) -> Result<T, OpError> {
468 if let Some(p) = payload {
469 return Ok(p);
470 }
471 if let Some(path) = &flags.answers {
472 return super::load_answers::<T>(path);
473 }
474 Err(OpError::InvalidArgument(
475 "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
476 ))
477}
478
479fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
480 EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
481}
482
483fn parse_deployment_id(raw: &str) -> Result<DeploymentId, OpError> {
484 use std::str::FromStr;
485 let ulid = ulid::Ulid::from_str(raw)
486 .map_err(|e| OpError::InvalidArgument(format!("deployment_id: {e}")))?;
487 Ok(DeploymentId(ulid))
488}
489
490fn parse_revision_id(raw: &str) -> Result<RevisionId, OpError> {
491 use std::str::FromStr;
492 let ulid = ulid::Ulid::from_str(raw)
493 .map_err(|e| OpError::InvalidArgument(format!("revision_id: {e}")))?;
494 Ok(RevisionId(ulid))
495}
496
497fn set_schema() -> Value {
498 json!({
499 "$schema": "https://json-schema.org/draft/2020-12/schema",
500 "title": "TrafficSetPayload",
501 "type": "object",
502 "required": ["environment_id", "deployment_id", "entries", "idempotency_key"],
503 "additionalProperties": false,
504 "properties": {
505 "environment_id": {"type": "string"},
506 "deployment_id": {"type": "string", "description": "ULID"},
507 "entries": {
508 "type": "array",
509 "items": {
510 "type": "object",
511 "required": ["revision_id"],
512 "properties": {
513 "revision_id": {"type": "string", "description": "ULID"},
514 "weight_bps": {"type": "integer", "minimum": 0, "maximum": 10000},
515 "weight_percent": {"type": "integer", "minimum": 0, "maximum": 100}
516 }
517 }
518 },
519 "updated_by": {"type": "string", "default": "operator"},
520 "idempotency_key": {"type": "string"},
521 "authorization_ref": {"type": "string"}
522 }
523 })
524}
525
526fn show_schema() -> Value {
527 json!({
528 "$schema": "https://json-schema.org/draft/2020-12/schema",
529 "title": "TrafficShowPayload",
530 "type": "object",
531 "required": ["environment_id", "deployment_id"],
532 "additionalProperties": false,
533 "properties": {
534 "environment_id": {"type": "string"},
535 "deployment_id": {"type": "string", "description": "ULID"},
536 "idempotency_key": {"type": "string"}
537 }
538 })
539}
540
541#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::cli::tests_common::{make_bundle_deployment, make_env, make_revision};
550 use greentic_deploy_spec::{RevisionLifecycle, SchemaVersion};
551 use tempfile::tempdir;
552
553 fn seed_env(store: &LocalFsStore) -> (DeploymentId, RevisionId, RevisionId) {
554 let mut env = make_env("local");
555 let deployment = make_bundle_deployment("local", "fast2flow");
556 let did = deployment.deployment_id;
557 let r1 = make_revision("local", "fast2flow", &did, 1, RevisionLifecycle::Ready);
558 let r2 = make_revision("local", "fast2flow", &did, 2, RevisionLifecycle::Ready);
559 let rid1 = r1.revision_id;
560 let rid2 = r2.revision_id;
561 env.bundles.push(deployment);
562 env.revisions.push(r1);
563 env.revisions.push(r2);
564 store.save(&env).unwrap();
565 (did, rid1, rid2)
566 }
567
568 #[test]
569 fn set_then_show_returns_split() {
570 let dir = tempdir().unwrap();
571 let store = LocalFsStore::new(dir.path());
572 let (did, rid1, _) = seed_env(&store);
573 let outcome = set(
574 &store,
575 &OpFlags::default(),
576 Some(TrafficSetPayload {
577 environment_id: "local".to_string(),
578 deployment_id: did.to_string(),
579 entries: vec![TrafficSetEntryPayload {
580 revision_id: rid1.to_string(),
581 weight_bps: Some(10_000),
582 weight_percent: None,
583 }],
584 updated_by: "test".to_string(),
585 idempotency_key: "k1".to_string(),
586 authorization_ref: default_authorization_ref(),
587 }),
588 )
589 .unwrap();
590 assert_eq!(
591 outcome.result.get("generation").and_then(|v| v.as_u64()),
592 Some(0)
593 );
594 let shown = show(
595 &store,
596 &OpFlags::default(),
597 Some(TrafficShowPayload {
598 environment_id: "local".to_string(),
599 deployment_id: did.to_string(),
600 idempotency_key: None,
601 }),
602 )
603 .unwrap();
604 let entries = shown
605 .result
606 .get("entries")
607 .and_then(|v| v.as_array())
608 .unwrap();
609 assert_eq!(entries.len(), 1);
610 }
611
612 #[test]
613 fn set_rejects_sum_not_10000() {
614 let dir = tempdir().unwrap();
615 let store = LocalFsStore::new(dir.path());
616 let (did, rid1, rid2) = seed_env(&store);
617 let err = set(
618 &store,
619 &OpFlags::default(),
620 Some(TrafficSetPayload {
621 environment_id: "local".to_string(),
622 deployment_id: did.to_string(),
623 entries: vec![
624 TrafficSetEntryPayload {
625 revision_id: rid1.to_string(),
626 weight_percent: Some(60),
627 weight_bps: None,
628 },
629 TrafficSetEntryPayload {
630 revision_id: rid2.to_string(),
631 weight_percent: Some(30),
632 weight_bps: None,
633 },
634 ],
635 updated_by: "test".to_string(),
636 idempotency_key: "k1".to_string(),
637 authorization_ref: default_authorization_ref(),
638 }),
639 )
640 .unwrap_err();
641 assert!(matches!(err, OpError::Spec(_)), "got {err:?}");
642 }
643
644 #[test]
645 fn set_then_rollback_restores_previous() {
646 let dir = tempdir().unwrap();
647 let store = LocalFsStore::new(dir.path());
648 let (did, rid1, rid2) = seed_env(&store);
649 set(
651 &store,
652 &OpFlags::default(),
653 Some(TrafficSetPayload {
654 environment_id: "local".to_string(),
655 deployment_id: did.to_string(),
656 entries: vec![TrafficSetEntryPayload {
657 revision_id: rid1.to_string(),
658 weight_percent: Some(100),
659 weight_bps: None,
660 }],
661 updated_by: "test".to_string(),
662 idempotency_key: "k1".to_string(),
663 authorization_ref: default_authorization_ref(),
664 }),
665 )
666 .unwrap();
667 set(
669 &store,
670 &OpFlags::default(),
671 Some(TrafficSetPayload {
672 environment_id: "local".to_string(),
673 deployment_id: did.to_string(),
674 entries: vec![
675 TrafficSetEntryPayload {
676 revision_id: rid1.to_string(),
677 weight_percent: Some(50),
678 weight_bps: None,
679 },
680 TrafficSetEntryPayload {
681 revision_id: rid2.to_string(),
682 weight_percent: Some(50),
683 weight_bps: None,
684 },
685 ],
686 updated_by: "test".to_string(),
687 idempotency_key: "k2".to_string(),
688 authorization_ref: default_authorization_ref(),
689 }),
690 )
691 .unwrap();
692 let rolled = rollback(
694 &store,
695 &OpFlags::default(),
696 Some(TrafficShowPayload {
697 environment_id: "local".to_string(),
698 deployment_id: did.to_string(),
699 idempotency_key: None,
700 }),
701 )
702 .unwrap();
703 let entries = rolled
704 .result
705 .get("entries")
706 .and_then(|v| v.as_array())
707 .unwrap();
708 assert_eq!(entries.len(), 1);
709 assert_eq!(
710 entries[0].get("weight_bps").and_then(|v| v.as_u64()),
711 Some(10_000)
712 );
713 }
714
715 #[test]
716 fn set_rejects_revision_from_other_deployment() {
717 let dir = tempdir().unwrap();
718 let store = LocalFsStore::new(dir.path());
719 let mut env = make_env("local");
721 let d1 = make_bundle_deployment("local", "fast2flow");
722 let did1 = d1.deployment_id;
723 let mut d2 = make_bundle_deployment("local", "llm-router");
724 d2.customer_id = greentic_deploy_spec::CustomerId::new("local-dev");
725 let did2 = d2.deployment_id;
727 let r1 = make_revision("local", "fast2flow", &did1, 1, RevisionLifecycle::Ready);
728 let r2 = make_revision("local", "llm-router", &did2, 1, RevisionLifecycle::Ready);
729 let rid2 = r2.revision_id;
730 env.bundles.push(d1);
731 env.bundles.push(d2);
732 env.revisions.push(r1);
733 env.revisions.push(r2);
734 store.save(&env).unwrap();
735
736 let err = set(
737 &store,
738 &OpFlags::default(),
739 Some(TrafficSetPayload {
740 environment_id: "local".to_string(),
741 deployment_id: did1.to_string(),
742 entries: vec![TrafficSetEntryPayload {
743 revision_id: rid2.to_string(),
745 weight_percent: Some(100),
746 weight_bps: None,
747 }],
748 updated_by: "test".to_string(),
749 idempotency_key: "k1".to_string(),
750 authorization_ref: default_authorization_ref(),
751 }),
752 )
753 .unwrap_err();
754 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
755 }
756
757 #[test]
758 fn set_same_idempotency_key_same_payload_is_no_op() {
759 let dir = tempdir().unwrap();
764 let store = LocalFsStore::new(dir.path());
765 let (did, rid1, _) = seed_env(&store);
766 let payload = TrafficSetPayload {
767 environment_id: "local".to_string(),
768 deployment_id: did.to_string(),
769 entries: vec![TrafficSetEntryPayload {
770 revision_id: rid1.to_string(),
771 weight_bps: Some(10_000),
772 weight_percent: None,
773 }],
774 updated_by: "test".to_string(),
775 idempotency_key: "k1".to_string(),
776 authorization_ref: default_authorization_ref(),
777 };
778 let first = set(&store, &OpFlags::default(), Some(payload.clone())).unwrap();
779 assert_eq!(
780 first.result.get("generation").and_then(|v| v.as_u64()),
781 Some(0)
782 );
783 let retry = set(&store, &OpFlags::default(), Some(payload)).unwrap();
785 assert_eq!(
786 retry.result.get("generation").and_then(|v| v.as_u64()),
787 Some(0),
788 "generation must stay at 0 on idempotent retry"
789 );
790 assert_eq!(
791 retry.result.get("has_previous").and_then(|v| v.as_bool()),
792 Some(false),
793 "previous_split_ref must stay empty on idempotent retry"
794 );
795 }
796
797 #[test]
798 fn set_same_idempotency_key_different_payload_conflicts() {
799 let dir = tempdir().unwrap();
800 let store = LocalFsStore::new(dir.path());
801 let (did, rid1, rid2) = seed_env(&store);
802 let p1 = TrafficSetPayload {
803 environment_id: "local".to_string(),
804 deployment_id: did.to_string(),
805 entries: vec![TrafficSetEntryPayload {
806 revision_id: rid1.to_string(),
807 weight_bps: Some(10_000),
808 weight_percent: None,
809 }],
810 updated_by: "test".to_string(),
811 idempotency_key: "k1".to_string(),
812 authorization_ref: default_authorization_ref(),
813 };
814 set(&store, &OpFlags::default(), Some(p1)).unwrap();
815 let p2 = TrafficSetPayload {
817 environment_id: "local".to_string(),
818 deployment_id: did.to_string(),
819 entries: vec![
820 TrafficSetEntryPayload {
821 revision_id: rid1.to_string(),
822 weight_percent: Some(50),
823 weight_bps: None,
824 },
825 TrafficSetEntryPayload {
826 revision_id: rid2.to_string(),
827 weight_percent: Some(50),
828 weight_bps: None,
829 },
830 ],
831 updated_by: "test".to_string(),
832 idempotency_key: "k1".to_string(),
833 authorization_ref: default_authorization_ref(),
834 };
835 let err = set(&store, &OpFlags::default(), Some(p2)).unwrap_err();
836 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
837 }
838
839 #[test]
840 fn set_retry_preserves_rollback_target() {
841 let dir = tempdir().unwrap();
846 let store = LocalFsStore::new(dir.path());
847 let (did, rid1, rid2) = seed_env(&store);
848 set(
850 &store,
851 &OpFlags::default(),
852 Some(TrafficSetPayload {
853 environment_id: "local".to_string(),
854 deployment_id: did.to_string(),
855 entries: vec![TrafficSetEntryPayload {
856 revision_id: rid1.to_string(),
857 weight_bps: Some(10_000),
858 weight_percent: None,
859 }],
860 updated_by: "test".to_string(),
861 idempotency_key: "k1".to_string(),
862 authorization_ref: default_authorization_ref(),
863 }),
864 )
865 .unwrap();
866 let k2_payload = TrafficSetPayload {
868 environment_id: "local".to_string(),
869 deployment_id: did.to_string(),
870 entries: vec![
871 TrafficSetEntryPayload {
872 revision_id: rid1.to_string(),
873 weight_percent: Some(50),
874 weight_bps: None,
875 },
876 TrafficSetEntryPayload {
877 revision_id: rid2.to_string(),
878 weight_percent: Some(50),
879 weight_bps: None,
880 },
881 ],
882 updated_by: "test".to_string(),
883 idempotency_key: "k2".to_string(),
884 authorization_ref: default_authorization_ref(),
885 };
886 set(&store, &OpFlags::default(), Some(k2_payload.clone())).unwrap();
887 set(&store, &OpFlags::default(), Some(k2_payload)).unwrap();
889 let rolled = rollback(
891 &store,
892 &OpFlags::default(),
893 Some(TrafficShowPayload {
894 environment_id: "local".to_string(),
895 deployment_id: did.to_string(),
896 idempotency_key: None,
897 }),
898 )
899 .unwrap();
900 let entries = rolled
901 .result
902 .get("entries")
903 .and_then(|v| v.as_array())
904 .unwrap();
905 assert_eq!(
906 entries.len(),
907 1,
908 "rollback must restore the single-entry k1 split, not the retried k2"
909 );
910 assert_eq!(
911 entries[0].get("weight_bps").and_then(|v| v.as_u64()),
912 Some(10_000)
913 );
914 }
915
916 #[test]
917 fn set_records_idempotency_key_and_generation_in_audit() {
918 let dir = tempdir().unwrap();
919 let store = LocalFsStore::new(dir.path());
920 let (did, rid1, _) = seed_env(&store);
921 set(
922 &store,
923 &OpFlags::default(),
924 Some(TrafficSetPayload {
925 environment_id: "local".to_string(),
926 deployment_id: did.to_string(),
927 entries: vec![TrafficSetEntryPayload {
928 revision_id: rid1.to_string(),
929 weight_bps: Some(10_000),
930 weight_percent: None,
931 }],
932 updated_by: "test".to_string(),
933 idempotency_key: "k1".to_string(),
934 authorization_ref: default_authorization_ref(),
935 }),
936 )
937 .unwrap();
938 let log = dir.path().join("local").join("audit").join("events.jsonl");
939 let raw = std::fs::read_to_string(&log).unwrap();
940 let event: crate::environment::AuditEvent = serde_json::from_str(raw.trim_end()).unwrap();
941 assert_eq!(event.noun, "traffic");
942 assert_eq!(event.verb, "set");
943 assert_eq!(event.idempotency_key.as_deref(), Some("k1"));
944 assert_eq!(event.previous_generation, None);
945 assert_eq!(event.new_generation, Some(0));
946 }
947
948 #[test]
949 fn set_materializes_runtime_config_on_disk() {
950 let dir = tempdir().unwrap();
954 let store = LocalFsStore::new(dir.path());
955 let (did, rid1, rid2) = seed_env(&store);
956 set(
957 &store,
958 &OpFlags::default(),
959 Some(TrafficSetPayload {
960 environment_id: "local".to_string(),
961 deployment_id: did.to_string(),
962 entries: vec![
963 TrafficSetEntryPayload {
964 revision_id: rid1.to_string(),
965 weight_percent: Some(90),
966 weight_bps: None,
967 },
968 TrafficSetEntryPayload {
969 revision_id: rid2.to_string(),
970 weight_percent: Some(10),
971 weight_bps: None,
972 },
973 ],
974 updated_by: "test".to_string(),
975 idempotency_key: "k1".to_string(),
976 authorization_ref: default_authorization_ref(),
977 }),
978 )
979 .unwrap();
980
981 let path = dir.path().join("local").join("runtime-config.json");
982 assert!(path.exists(), "runtime-config.json must be materialized");
983 let cfg: greentic_deploy_spec::RuntimeConfig =
984 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
985 assert_eq!(cfg.schema.as_str(), SchemaVersion::RUNTIME_CONFIG_V1);
986 assert_eq!(cfg.env_id.as_str(), "local");
987 assert_eq!(cfg.revisions.len(), 2);
988 let sum: u32 = cfg.revisions.iter().map(|b| b.weight_bps).sum();
989 assert_eq!(sum, 10_000);
990 }
991
992 #[test]
993 fn refresh_deletes_runtime_config_when_no_splits_remain() {
994 let dir = tempdir().unwrap();
997 let store = LocalFsStore::new(dir.path());
998 let (did, rid1, _) = seed_env(&store);
999 set(
1000 &store,
1001 &OpFlags::default(),
1002 Some(TrafficSetPayload {
1003 environment_id: "local".to_string(),
1004 deployment_id: did.to_string(),
1005 entries: vec![TrafficSetEntryPayload {
1006 revision_id: rid1.to_string(),
1007 weight_bps: Some(10_000),
1008 weight_percent: None,
1009 }],
1010 updated_by: "test".to_string(),
1011 idempotency_key: "k1".to_string(),
1012 authorization_ref: default_authorization_ref(),
1013 }),
1014 )
1015 .unwrap();
1016 let path = dir.path().join("local").join("runtime-config.json");
1017 assert!(path.exists());
1018
1019 let env_id = EnvId::try_from("local").unwrap();
1021 let mut env = store.load(&env_id).unwrap();
1022 env.traffic_splits.clear();
1023 store.save(&env).unwrap();
1024 store
1025 .transact(&env_id, |locked| {
1026 let env = locked.load()?;
1027 locked.refresh_runtime_config(&env)
1028 })
1029 .unwrap();
1030 assert!(
1031 !path.exists(),
1032 "runtime-config.json must be removed when no split routes a revision"
1033 );
1034 }
1035
1036 #[test]
1037 fn refresh_is_noop_when_projection_unchanged() {
1038 let dir = tempdir().unwrap();
1041 let store = LocalFsStore::new(dir.path());
1042 let (did, rid1, _) = seed_env(&store);
1043 set(
1044 &store,
1045 &OpFlags::default(),
1046 Some(TrafficSetPayload {
1047 environment_id: "local".to_string(),
1048 deployment_id: did.to_string(),
1049 entries: vec![TrafficSetEntryPayload {
1050 revision_id: rid1.to_string(),
1051 weight_bps: Some(10_000),
1052 weight_percent: None,
1053 }],
1054 updated_by: "test".to_string(),
1055 idempotency_key: "k1".to_string(),
1056 authorization_ref: default_authorization_ref(),
1057 }),
1058 )
1059 .unwrap();
1060 let path = dir.path().join("local").join("runtime-config.json");
1061 let before = std::fs::read(&path).unwrap();
1062
1063 let env_id = EnvId::try_from("local").unwrap();
1064 store
1065 .transact(&env_id, |locked| {
1066 let env = locked.load()?;
1067 locked.refresh_runtime_config(&env)
1068 })
1069 .unwrap();
1070
1071 assert_eq!(std::fs::read(&path).unwrap(), before);
1072 let backups = dir.path().join("local").join("backups");
1073 let backup_count = std::fs::read_dir(&backups)
1074 .map(|rd| {
1075 rd.filter_map(Result::ok)
1076 .filter(|e| {
1077 e.file_name()
1078 .to_string_lossy()
1079 .starts_with("runtime-config.json")
1080 })
1081 .count()
1082 })
1083 .unwrap_or(0);
1084 assert_eq!(backup_count, 0, "no-op refresh must not back up the config");
1085 }
1086
1087 #[test]
1088 fn set_rejects_non_ready_revision_and_materializes_nothing() {
1089 let dir = tempdir().unwrap();
1092 let store = LocalFsStore::new(dir.path());
1093 let mut env = make_env("local");
1094 let dep = make_bundle_deployment("local", "fast2flow");
1095 let did = dep.deployment_id;
1096 let staged = make_revision("local", "fast2flow", &did, 1, RevisionLifecycle::Staged);
1097 let rid = staged.revision_id;
1098 env.bundles.push(dep);
1099 env.revisions.push(staged);
1100 store.save(&env).unwrap();
1101
1102 let err = set(
1103 &store,
1104 &OpFlags::default(),
1105 Some(TrafficSetPayload {
1106 environment_id: "local".to_string(),
1107 deployment_id: did.to_string(),
1108 entries: vec![TrafficSetEntryPayload {
1109 revision_id: rid.to_string(),
1110 weight_bps: Some(10_000),
1111 weight_percent: None,
1112 }],
1113 updated_by: "test".to_string(),
1114 idempotency_key: "k1".to_string(),
1115 authorization_ref: default_authorization_ref(),
1116 }),
1117 )
1118 .unwrap_err();
1119 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1120 assert!(
1121 !dir.path()
1122 .join("local")
1123 .join("runtime-config.json")
1124 .exists(),
1125 "a rejected set must not leave a runtime-config behind"
1126 );
1127 }
1128
1129 #[test]
1130 fn rollback_refuses_when_restored_revision_no_longer_ready() {
1131 let dir = tempdir().unwrap();
1134 let store = LocalFsStore::new(dir.path());
1135 let (did, rid1, rid2) = seed_env(&store);
1136 let base = |key: &str, rid: &RevisionId| TrafficSetPayload {
1137 environment_id: "local".to_string(),
1138 deployment_id: did.to_string(),
1139 entries: vec![TrafficSetEntryPayload {
1140 revision_id: rid.to_string(),
1141 weight_bps: Some(10_000),
1142 weight_percent: None,
1143 }],
1144 updated_by: "test".to_string(),
1145 idempotency_key: key.to_string(),
1146 authorization_ref: default_authorization_ref(),
1147 };
1148 set(&store, &OpFlags::default(), Some(base("k1", &rid1))).unwrap();
1150 set(&store, &OpFlags::default(), Some(base("k2", &rid2))).unwrap();
1151
1152 let env_id = EnvId::try_from("local").unwrap();
1154 let mut env = store.load(&env_id).unwrap();
1155 let i = env
1156 .revisions
1157 .iter()
1158 .position(|r| r.revision_id == rid1)
1159 .unwrap();
1160 env.revisions[i].lifecycle = RevisionLifecycle::Archived;
1161 store.save(&env).unwrap();
1162
1163 let err = rollback(
1164 &store,
1165 &OpFlags::default(),
1166 Some(TrafficShowPayload {
1167 environment_id: "local".to_string(),
1168 deployment_id: did.to_string(),
1169 idempotency_key: None,
1170 }),
1171 )
1172 .unwrap_err();
1173 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1174 }
1175
1176 #[test]
1177 fn idempotent_retry_reconciles_stale_runtime_config() {
1178 let dir = tempdir().unwrap();
1181 let store = LocalFsStore::new(dir.path());
1182 let (did, rid1, _) = seed_env(&store);
1183 let payload = TrafficSetPayload {
1184 environment_id: "local".to_string(),
1185 deployment_id: did.to_string(),
1186 entries: vec![TrafficSetEntryPayload {
1187 revision_id: rid1.to_string(),
1188 weight_bps: Some(10_000),
1189 weight_percent: None,
1190 }],
1191 updated_by: "test".to_string(),
1192 idempotency_key: "k1".to_string(),
1193 authorization_ref: default_authorization_ref(),
1194 };
1195 set(&store, &OpFlags::default(), Some(payload.clone())).unwrap();
1196 let path = dir.path().join("local").join("runtime-config.json");
1197 assert!(path.exists());
1198
1199 std::fs::remove_file(&path).unwrap();
1201
1202 set(&store, &OpFlags::default(), Some(payload)).unwrap();
1204 assert!(
1205 path.exists(),
1206 "idempotent retry must reconcile a stale runtime-config"
1207 );
1208 }
1209
1210 #[test]
1213 fn parse_entry_arg_accepts_plain_percent() {
1214 let e = parse_entry_arg("01H000000000000000000000R1=99").unwrap();
1215 assert_eq!(e.revision_id, "01H000000000000000000000R1");
1216 assert_eq!(e.weight_bps, None);
1217 assert_eq!(e.weight_percent, Some(99));
1218 }
1219
1220 #[test]
1221 fn parse_entry_arg_accepts_percent_suffix() {
1222 let e = parse_entry_arg("rev1=50%").unwrap();
1223 assert_eq!(e.weight_percent, Some(50));
1224 assert_eq!(e.weight_bps, None);
1225 }
1226
1227 #[test]
1228 fn parse_entry_arg_accepts_basis_points() {
1229 let e = parse_entry_arg("rev1=2500bps").unwrap();
1230 assert_eq!(e.weight_bps, Some(2500));
1231 assert_eq!(e.weight_percent, None);
1232 }
1233
1234 #[test]
1235 fn parse_entry_arg_rejects_missing_separator() {
1236 let err = parse_entry_arg("rev1-99").unwrap_err();
1237 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1238 }
1239
1240 #[test]
1241 fn parse_entry_arg_rejects_empty_revision_id() {
1242 let err = parse_entry_arg("=99").unwrap_err();
1243 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1244 }
1245
1246 #[test]
1247 fn parse_entry_arg_rejects_empty_weight() {
1248 let err = parse_entry_arg("rev1=").unwrap_err();
1249 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1250 }
1251
1252 #[test]
1253 fn parse_entry_arg_rejects_non_numeric_weight() {
1254 let err = parse_entry_arg("rev1=many").unwrap_err();
1255 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1256 }
1257
1258 #[test]
1259 fn parse_entry_arg_rejects_non_numeric_bps() {
1260 let err = parse_entry_arg("rev1=manybps").unwrap_err();
1261 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1262 }
1263
1264 #[test]
1265 fn payload_from_set_args_returns_none_when_blank() {
1266 let args = TrafficSetArgs {
1267 env_id: None,
1268 entries: Vec::new(),
1269 deployment: None,
1270 idempotency_key: None,
1271 updated_by: None,
1272 authorization_ref: None,
1273 };
1274 assert!(payload_from_set_args(args).unwrap().is_none());
1275 }
1276
1277 #[test]
1278 fn payload_from_set_args_requires_deployment_when_env_present() {
1279 let args = TrafficSetArgs {
1280 env_id: Some("local".to_string()),
1281 entries: vec!["rev1=100".to_string()],
1282 deployment: None,
1283 idempotency_key: None,
1284 updated_by: None,
1285 authorization_ref: None,
1286 };
1287 let err = payload_from_set_args(args).unwrap_err();
1288 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1289 }
1290
1291 #[test]
1292 fn payload_from_set_args_requires_entries() {
1293 let args = TrafficSetArgs {
1294 env_id: Some("local".to_string()),
1295 entries: Vec::new(),
1296 deployment: Some(DeploymentId::new().to_string()),
1297 idempotency_key: None,
1298 updated_by: None,
1299 authorization_ref: None,
1300 };
1301 let err = payload_from_set_args(args).unwrap_err();
1302 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1303 }
1304
1305 #[test]
1306 fn payload_from_set_args_requires_idempotency_key() {
1307 let did = DeploymentId::new();
1312 let args = TrafficSetArgs {
1313 env_id: Some("local".to_string()),
1314 entries: vec!["rev1=100".to_string()],
1315 deployment: Some(did.to_string()),
1316 idempotency_key: None,
1317 updated_by: None,
1318 authorization_ref: None,
1319 };
1320 let err = payload_from_set_args(args).unwrap_err();
1321 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1322 let OpError::InvalidArgument(msg) = err else {
1323 unreachable!()
1324 };
1325 assert!(
1326 msg.contains("--idempotency-key"),
1327 "error must mention the flag, got `{msg}`"
1328 );
1329 }
1330
1331 #[test]
1332 fn payload_from_set_args_builds_full_payload_with_defaults() {
1333 let did = DeploymentId::new();
1334 let args = TrafficSetArgs {
1335 env_id: Some("local".to_string()),
1336 entries: vec!["rev1=90".to_string(), "rev2=10bps".to_string()],
1337 deployment: Some(did.to_string()),
1338 idempotency_key: Some("k-test".to_string()),
1339 updated_by: None,
1340 authorization_ref: None,
1341 };
1342 let payload = payload_from_set_args(args).unwrap().unwrap();
1343 assert_eq!(payload.environment_id, "local");
1344 assert_eq!(payload.deployment_id, did.to_string());
1345 assert_eq!(payload.entries.len(), 2);
1346 assert_eq!(payload.entries[0].weight_percent, Some(90));
1347 assert_eq!(payload.entries[1].weight_bps, Some(10));
1348 assert_eq!(payload.updated_by, "operator", "default actor");
1349 assert_eq!(
1350 payload.authorization_ref,
1351 PathBuf::from("auth.json"),
1352 "default authorization ref"
1353 );
1354 assert_eq!(payload.idempotency_key, "k-test");
1355 }
1356
1357 #[test]
1358 fn payload_from_set_args_honors_explicit_overrides() {
1359 let did = DeploymentId::new();
1360 let args = TrafficSetArgs {
1361 env_id: Some("local".to_string()),
1362 entries: vec!["rev1=100".to_string()],
1363 deployment: Some(did.to_string()),
1364 idempotency_key: Some("k-explicit".to_string()),
1365 updated_by: Some("ci".to_string()),
1366 authorization_ref: Some(PathBuf::from("custom.json")),
1367 };
1368 let payload = payload_from_set_args(args).unwrap().unwrap();
1369 assert_eq!(payload.idempotency_key, "k-explicit");
1370 assert_eq!(payload.updated_by, "ci");
1371 assert_eq!(payload.authorization_ref, PathBuf::from("custom.json"));
1372 }
1373
1374 #[test]
1375 fn payload_from_target_args_returns_none_when_blank() {
1376 let args = TrafficTargetArgs {
1377 env_id: None,
1378 deployment: None,
1379 };
1380 assert!(payload_from_target_args(args).unwrap().is_none());
1381 }
1382
1383 #[test]
1384 fn payload_from_target_args_requires_both_when_either_present() {
1385 let args = TrafficTargetArgs {
1386 env_id: Some("local".to_string()),
1387 deployment: None,
1388 };
1389 let err = payload_from_target_args(args).unwrap_err();
1390 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1391 }
1392
1393 #[test]
1394 fn payload_from_target_args_builds_payload() {
1395 let did = DeploymentId::new();
1396 let args = TrafficTargetArgs {
1397 env_id: Some("local".to_string()),
1398 deployment: Some(did.to_string()),
1399 };
1400 let payload = payload_from_target_args(args).unwrap().unwrap();
1401 assert_eq!(payload.environment_id, "local");
1402 assert_eq!(payload.deployment_id, did.to_string());
1403 }
1404
1405 #[test]
1406 fn clap_set_payload_drives_real_traffic_set() {
1407 let dir = tempdir().unwrap();
1411 let store = LocalFsStore::new(dir.path());
1412 let (did, rid1, rid2) = seed_env(&store);
1413 let args = TrafficSetArgs {
1414 env_id: Some("local".to_string()),
1415 entries: vec![format!("{rid1}=90"), format!("{rid2}=10")],
1416 deployment: Some(did.to_string()),
1417 idempotency_key: Some("k-clap".to_string()),
1418 updated_by: Some("test".to_string()),
1419 authorization_ref: None,
1420 };
1421 let payload = payload_from_set_args(args).unwrap();
1422 let outcome = set(&store, &OpFlags::default(), payload).unwrap();
1423 let entries = outcome
1424 .result
1425 .get("entries")
1426 .and_then(|v| v.as_array())
1427 .unwrap();
1428 assert_eq!(entries.len(), 2);
1429 let total: u64 = entries
1430 .iter()
1431 .map(|e| e.get("weight_bps").and_then(|v| v.as_u64()).unwrap())
1432 .sum();
1433 assert_eq!(total, 10_000, "clap-built payload must satisfy spec sum");
1434 }
1435
1436 #[test]
1437 fn clap_retry_preserves_rollback_target() {
1438 let dir = tempdir().unwrap();
1443 let store = LocalFsStore::new(dir.path());
1444 let (did, rid1, rid2) = seed_env(&store);
1445
1446 let a_args = TrafficSetArgs {
1448 env_id: Some("local".to_string()),
1449 entries: vec![format!("{rid1}=100")],
1450 deployment: Some(did.to_string()),
1451 idempotency_key: Some("k1".to_string()),
1452 updated_by: Some("test".to_string()),
1453 authorization_ref: None,
1454 };
1455 set(
1456 &store,
1457 &OpFlags::default(),
1458 payload_from_set_args(a_args).unwrap(),
1459 )
1460 .unwrap();
1461
1462 let b_args = || TrafficSetArgs {
1464 env_id: Some("local".to_string()),
1465 entries: vec![format!("{rid1}=50"), format!("{rid2}=50")],
1466 deployment: Some(did.to_string()),
1467 idempotency_key: Some("k2".to_string()),
1468 updated_by: Some("test".to_string()),
1469 authorization_ref: None,
1470 };
1471 set(
1472 &store,
1473 &OpFlags::default(),
1474 payload_from_set_args(b_args()).unwrap(),
1475 )
1476 .unwrap();
1477
1478 let retry = set(
1483 &store,
1484 &OpFlags::default(),
1485 payload_from_set_args(b_args()).unwrap(),
1486 )
1487 .unwrap();
1488 assert_eq!(
1489 retry.result.get("generation").and_then(|v| v.as_u64()),
1490 Some(1),
1491 "retry must replay B's generation, not advance"
1492 );
1493
1494 let rolled = rollback(
1496 &store,
1497 &OpFlags::default(),
1498 Some(TrafficShowPayload {
1499 environment_id: "local".to_string(),
1500 deployment_id: did.to_string(),
1501 idempotency_key: None,
1502 }),
1503 )
1504 .unwrap();
1505 let entries = rolled
1506 .result
1507 .get("entries")
1508 .and_then(|v| v.as_array())
1509 .unwrap();
1510 assert_eq!(
1511 entries.len(),
1512 1,
1513 "rollback must land on A (single entry), not the retried B"
1514 );
1515 assert_eq!(
1516 entries[0].get("weight_bps").and_then(|v| v.as_u64()),
1517 Some(10_000),
1518 "rollback must restore 100% rev1"
1519 );
1520 }
1521
1522 use crate::rollout_telemetry::test_capture::{capture_events, count};
1538 use std::collections::BTreeSet;
1539
1540 fn observed(events: &[String]) -> BTreeSet<String> {
1541 events.iter().cloned().collect()
1542 }
1543
1544 fn set_payload(did: &DeploymentId, rid: &RevisionId, key: &str) -> TrafficSetPayload {
1545 TrafficSetPayload {
1546 environment_id: "local".to_string(),
1547 deployment_id: did.to_string(),
1548 entries: vec![TrafficSetEntryPayload {
1549 revision_id: rid.to_string(),
1550 weight_bps: Some(10_000),
1551 weight_percent: None,
1552 }],
1553 updated_by: "test".to_string(),
1554 idempotency_key: key.to_string(),
1555 authorization_ref: default_authorization_ref(),
1556 }
1557 }
1558
1559 #[test]
1560 fn set_emits_traffic_split_applied() {
1561 let dir = tempdir().unwrap();
1562 let store = LocalFsStore::new(dir.path());
1563 let (did, rid1, _) = seed_env(&store);
1564 let (res, events) = capture_events(|| {
1565 set(
1566 &store,
1567 &OpFlags::default(),
1568 Some(set_payload(&did, &rid1, "k1")),
1569 )
1570 });
1571 res.unwrap();
1572 let observed = observed(&events);
1573 assert!(
1574 observed.contains("rollout.traffic_split.applied"),
1575 "observed events: {observed:?}"
1576 );
1577 }
1578
1579 #[test]
1584 fn set_idempotent_replay_does_not_double_emit() {
1585 let dir = tempdir().unwrap();
1586 let store = LocalFsStore::new(dir.path());
1587 let (did, rid1, _) = seed_env(&store);
1588 let (res, events) = capture_events(|| {
1589 set(
1590 &store,
1591 &OpFlags::default(),
1592 Some(set_payload(&did, &rid1, "k1")),
1593 )
1594 .unwrap();
1595 set(
1598 &store,
1599 &OpFlags::default(),
1600 Some(set_payload(&did, &rid1, "k1")),
1601 )
1602 });
1603 res.unwrap();
1604 assert_eq!(
1605 count(&events, "rollout.traffic_split.applied"),
1606 1,
1607 "expected exactly one TrafficSplitApplied event across set + replay; \
1608 captured: {:?}",
1609 observed(&events)
1610 );
1611 }
1612
1613 #[test]
1618 fn rollback_emits_traffic_split_applied() {
1619 let dir = tempdir().unwrap();
1620 let store = LocalFsStore::new(dir.path());
1621 let (did, rid1, rid2) = seed_env(&store);
1622 set(
1625 &store,
1626 &OpFlags::default(),
1627 Some(set_payload(&did, &rid1, "k1")),
1628 )
1629 .unwrap();
1630 set(
1631 &store,
1632 &OpFlags::default(),
1633 Some(TrafficSetPayload {
1634 environment_id: "local".to_string(),
1635 deployment_id: did.to_string(),
1636 entries: vec![
1637 TrafficSetEntryPayload {
1638 revision_id: rid1.to_string(),
1639 weight_bps: Some(5_000),
1640 weight_percent: None,
1641 },
1642 TrafficSetEntryPayload {
1643 revision_id: rid2.to_string(),
1644 weight_bps: Some(5_000),
1645 weight_percent: None,
1646 },
1647 ],
1648 updated_by: "test".to_string(),
1649 idempotency_key: "k2".to_string(),
1650 authorization_ref: default_authorization_ref(),
1651 }),
1652 )
1653 .unwrap();
1654
1655 let (res, events) = capture_events(|| {
1656 rollback(
1657 &store,
1658 &OpFlags::default(),
1659 Some(TrafficShowPayload {
1660 environment_id: "local".to_string(),
1661 deployment_id: did.to_string(),
1662 idempotency_key: None,
1663 }),
1664 )
1665 });
1666 res.unwrap();
1667 assert_eq!(
1668 count(&events, "rollout.traffic_split.applied"),
1669 1,
1670 "rollback must emit exactly one TrafficSplitApplied; \
1671 captured: {:?}",
1672 observed(&events)
1673 );
1674 }
1675
1676 #[test]
1682 fn set_schema_lists_idempotency_key() {
1683 let schema = set_schema();
1684 assert!(
1685 schema.pointer("/properties/idempotency_key").is_some(),
1686 "set_schema must list `idempotency_key` (schema: {schema:#})"
1687 );
1688 }
1689
1690 #[test]
1695 fn show_schema_lists_idempotency_key() {
1696 let schema = show_schema();
1697 assert!(
1698 schema.pointer("/properties/idempotency_key").is_some(),
1699 "show_schema must list `idempotency_key` (schema: {schema:#})"
1700 );
1701 }
1702}