Skip to main content

greentic_deployer/cli/
traffic.rs

1//! `gtc op traffic {set,show,rollback}` (`A3`).
2//!
3//! Manages `Environment.traffic_splits: Vec<TrafficSplit>`. Each split is
4//! per-`deployment_id`. The CLI accepts percentages or basis points and
5//! validates the entries sum to exactly 10,000 bps via
6//! [`TrafficSplit::validate`].
7//!
8//! Rollback: the prior `TrafficSplit` is stashed inline under
9//! `previous_split_ref` using the `inline://<base64>` token scheme from
10//! `greentic_deploy_spec::engine::inline_stash` so rollback works without a
11//! sidecar history file. Multi-step history is A8's contract.
12//!
13//! In-process router wiring (the `RevisionDispatcher` in `greentic-start`)
14//! is Phase B. A3 only mutates the spec object; making it observable in the
15//! live runtime is a separate gate.
16
17use 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    /// Basis points. `weight_bps` and `weight_percent` are mutually
49    /// exclusive at the payload level; if both are set, `weight_bps` wins.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub weight_bps: Option<u32>,
52    /// Percentage 0..=100, converted to basis points.
53    #[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
100/// `op traffic set`. Replaces the entire entries list for one deployment.
101/// Validates sum == 10,000 bps before saving. Stashes the prior split for
102/// one-step rollback.
103pub 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    // Pre-parse + pre-validate the entries outside the lock. If anything
115    // here is malformed the caller hears about it without contending for
116    // the env's flock.
117    let parsed_entries = parse_entries(&payload.entries)?;
118    // Revision-belongs-to-deployment check (operator-friendly error
119    // instead of waiting for the store's defense-in-depth guard to fire
120    // with a less specific error variant).
121    {
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
186/// C5.3: emit `TrafficSplitApplied` from the outcome's env snapshot — the
187/// same env the store just persisted, so tenant attribution has no TOCTOU
188/// window with a concurrent writer, local or remote. The idempotent no-op
189/// replay carries `new_generation: None` and must NOT double-count the
190/// transition.
191pub(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
202/// C5.3: emit `TrafficSplitApplied` for the rollback path too — a rollback
203/// advances generation and materializes into runtime-config exactly like a
204/// forward `set`, so monitoring pipelines need the same lifecycle event.
205pub(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
214/// Build a [`TrafficSetPayload`] from clap-derived args.
215///
216/// Returns `Ok(None)` when no clap args were supplied — the caller falls back
217/// to `--answers` / `--payload-json`. Returns `Ok(Some(_))` when the user
218/// passed positional args. A partial set (e.g. `env_id` without
219/// `--deployment`) is a clap-level user error and surfaces as
220/// [`OpError::InvalidArgument`] so the user gets one clear message instead
221/// of being silently routed to the answers path.
222pub 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    // No positional args at all → answers/schema path.
232    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    // Required: an auto-generated key per-invocation would break the
247    // rollback target preservation contract — a same-argv retry after a
248    // lost response would look like a brand-new mutation, snapshotting the
249    // already-live split as `previous_split_ref` and overwriting the real
250    // rollback target. The answers-path schema treats `idempotency_key`
251    // as required; the direct-args path matches.
252    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
275/// Build a [`TrafficShowPayload`] for `op traffic show` and `op traffic rollback`.
276/// Same fallthrough semantics as [`payload_from_set_args`].
277pub 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
297/// Parse a `<revision_id>=<weight>` CLI argument into a payload entry.
298///
299/// Weight forms:
300/// - `N` or `N%` — percent (`0..=100`)
301/// - `Nbps` — basis points (`0..=10_000`)
302fn 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    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
373    /// surface; when absent, the verb mints one per invocation.
374    #[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
450// --- internals -----------------------------------------------------------
451
452/// Traffic-specific `StoreError → OpError` mapper that peels
453/// [`crate::environment::StoreError::Spec`] into [`OpError::Spec`] before
454/// falling through to [`map_store_err_preserving_noun`]. The Spec variant
455/// is unique to traffic (validate sum == 10,000 bps) and the shared
456/// mapper doesn't cover it.
457pub(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// The `inline://` stash scheme moved to
542// `greentic_deploy_spec::engine::inline_stash` in PR-4.2c — the pure
543// traffic transforms write/read the tokens, so both backends must share
544// one implementation.
545
546#[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        // First split: 100% rev1.
650        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        // Second split: 50/50.
668        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        // Rollback: should restore split-1 with 100% rev1.
693        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        // Seed env with two deployments, two revisions, one in each.
720        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        // Force a distinct (bundle, customer) — they already differ on bundle.
726        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                    // Cross-deployment revision_id — must be rejected.
744                    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        // Codex regression: a retried set with the same key + same entries
760        // must not snapshot the current split as its own previous_split_ref
761        // (which would orphan the real rollback target). Verify generation
762        // stays put and previous_split_ref stays empty.
763        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        // Retry with the same key + same payload — must replay as no-op.
784        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        // Same key, different entries.
816        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        // Codex regression: prior to the idempotency check, a retried set
842        // would overwrite previous_split_ref with itself, and a later
843        // rollback would land on the retried split instead of the pre-change
844        // traffic. Verify the rollback target is still the pre-change split.
845        let dir = tempdir().unwrap();
846        let store = LocalFsStore::new(dir.path());
847        let (did, rid1, rid2) = seed_env(&store);
848        // k1: 100% rev1.
849        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        // k2: 50/50. This is the change rollback must undo.
867        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        // Retry k2 — should be no-op, must not overwrite previous_split_ref.
888        set(&store, &OpFlags::default(), Some(k2_payload)).unwrap();
889        // Rollback: must restore 100% rev1, not the retried 50/50.
890        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        // B4 producer: a traffic set must (re)write runtime-config.json — the
951        // file greentic-start boots from (B0) and routes on (B3) — and the
952        // projection must satisfy B0's per-deployment weight invariant.
953        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        // B0 rejects an empty-revisions config, so when the last split is gone
995        // the producer must delete the stale file rather than write an empty one.
996        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        // Drop the split out-of-band, then refresh under the lock.
1020        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        // Change-detection guard: refreshing without a traffic mutation must
1039        // not rewrite or back up the file.
1040        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        // §5.3 admission: routing traffic to a Staged revision must be refused,
1090        // and no runtime-config may be produced.
1091        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        // §5.3 admission on the restore path: a historical split routing a
1132        // since-archived revision must not be brought live.
1133        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        // k1 routes rid1; k2 routes rid2 (so the rollback target is k1/rid1).
1149        set(&store, &OpFlags::default(), Some(base("k1", &rid1))).unwrap();
1150        set(&store, &OpFlags::default(), Some(base("k2", &rid2))).unwrap();
1151
1152        // rid1 retires out-of-band (it is not in the live k2 split).
1153        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        // A publish that failed after environment.json was durable must be
1179        // repaired by a same-key retry, not hidden by the no-op replay.
1180        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        // Simulate a refresh that failed after the split was already saved.
1200        std::fs::remove_file(&path).unwrap();
1201
1202        // Idempotent retry (same key + entries) must reconcile the file.
1203        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    // --- clap-arg parser tests -----------------------------------------
1211
1212    #[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        // Direct-arg path mirrors the answers-schema contract: idempotency
1308        // key is required. Auto-generating one per invocation would let a
1309        // same-argv retry advance generation and overwrite the rollback
1310        // target — see `clap_retry_preserves_rollback_target`.
1311        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        // End-to-end smoke: payload_from_set_args + traffic::set against a
1408        // real store. Verifies the CLI surface drives the library happy path
1409        // with no answers file in sight.
1410        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        // Regression: a retried direct-args invocation with the same
1439        // --idempotency-key must replay as a no-op, NOT advance the
1440        // generation and snapshot the live split as previous_split_ref —
1441        // otherwise the one-step rollback target is destroyed.
1442        let dir = tempdir().unwrap();
1443        let store = LocalFsStore::new(dir.path());
1444        let (did, rid1, rid2) = seed_env(&store);
1445
1446        // A: 100% rev1 (k1).
1447        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        // B: 50/50 (k2). This is the change a rollback should undo.
1463        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        // Retry B through the clap path — must be a no-op replay because
1479        // the key matches. Without the explicit-key requirement, the CLI
1480        // would generate a fresh ULID here and the library would treat it
1481        // as a new mutation, overwriting the (A) rollback target.
1482        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        // Rollback must restore A (100% rev1), not the retried B (50/50).
1495        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    // -------------------------------------------------------------------
1523    // C5.3 — end-to-end rollout-event capture for traffic verbs
1524    //
1525    // Uses the shared global capture from
1526    // `crate::rollout_telemetry::test_capture` (see that module's doc for
1527    // why a global subscriber is required instead of per-test
1528    // `with_default` — `tracing`'s callsite interest cache races under
1529    // parallel test execution). Asserts:
1530    // - `set` on a genuine mutation emits `rollout.traffic_split.applied`.
1531    // - `set` on an idempotent same-key-same-entries replay does NOT
1532    //   double-emit (regression guard for the early-return guard).
1533    // - `rollback` emits `rollout.traffic_split.applied` exactly like a
1534    //   forward `set` (parity with the runtime mutation profile).
1535    // -------------------------------------------------------------------
1536
1537    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    /// Regression guard: a same-key-same-entries replay returns the early
1580    /// `AuditGens::NONE` from inside the transact, BEFORE reaching the
1581    /// `emit_traffic_split_applied` call. The replay must NOT double-count
1582    /// the transition — exactly one event should appear across both calls.
1583    #[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            // Replay: same key, same entries → no-op success per the
1596            // idempotency contract; must NOT emit a second event.
1597            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    /// `rollback` advances generation and materializes into runtime-config
1614    /// exactly like a forward `set`, so it must emit the same lifecycle
1615    /// event. Without this an emergency rollback would produce zero
1616    /// telemetry confirmation.
1617    #[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        // Establish a prior split (k1, 100% rev1), then overwrite (k2,
1623        // 50/50). Now there's a previous_split_ref for rollback to consume.
1624        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    // --- PR-3a.11: schema regression tests for idempotency_key ---------------
1677
1678    /// `TrafficSetPayload` accepts `idempotency_key`; the schema published
1679    /// via `--schema` MUST list it so schema-driven callers can supply the
1680    /// A8 retry key.
1681    #[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    /// Same gate for the show/rollback schema: `TrafficShowPayload` now
1691    /// accepts `idempotency_key`, so the schema must list it under
1692    /// `properties` (especially because `additionalProperties: false`
1693    /// would reject it otherwise).
1694    #[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}