Skip to main content

workload_spec/
rollout.rs

1//! Rollout policy schema — the typed form of `.yubaba/rollout.toml`.
2//!
3//! Policy files live in the release bundle alongside the `WorkloadSpec` they
4//! govern. Yubaba deserialises the policy and drives the rollout according to
5//! the declared strategy, gates, and steps.
6//!
7//! Corresponds to §"Rollout policy" in `.yah/docs/working/W140-yah-yubaba-ci-cd.md`.
8
9use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "json-schema")]
12use schemars::JsonSchema;
13
14/// Top-level wrapper when reading `.yubaba/rollout.toml` from disk.
15///
16/// TOML files have a `[rollout]` section; when the policy is inlined in JSON
17/// (e.g. in the `POST /v1/rollouts` request body), use [`RolloutPolicy`] directly.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
20pub struct RolloutFile {
21    pub rollout: RolloutPolicy,
22}
23
24/// Rollout policy for a single service — the content of the `[rollout]` TOML section.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
27pub struct RolloutPolicy {
28    /// Deployment strategy. Only `linear` is implemented in yubaba v1.
29    pub strategy: RolloutStrategy,
30    /// Maximum wall-clock seconds for the entire rollout before it times out.
31    pub window_seconds: u64,
32    /// SLO gates evaluated after each step's gate window elapses.
33    #[serde(default)]
34    pub gates: Vec<RolloutGate>,
35    /// Ordered deployment steps (e.g. staging → canary → prod).
36    #[serde(default)]
37    pub steps: Vec<RolloutStep>,
38}
39
40/// Rollout strategy.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
43#[serde(rename_all = "kebab-case")]
44pub enum RolloutStrategy {
45    /// Deploy to each step's mirrors in sequence; promote only when all gates pass.
46    Linear,
47    /// Deploy to a configurable fraction of mirrors per step. Not implemented in v1.
48    CanaryFraction,
49}
50
51/// A single SLO gate evaluated after a step's gate window elapses.
52///
53/// All gates must pass before the rollout advances to the next step.
54/// A gate failure triggers the step's `on_failure` action.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
57pub struct RolloutGate {
58    /// Metric name. v1 yubaba supports `http_5xx_rate` and `p95_latency_ms`;
59    /// arbitrary PromQL expressions are also accepted.
60    pub metric: String,
61    /// Comparison condition, e.g. `"< 0.01"` or `"< 200"`.
62    /// Operators: `<`, `<=`, `>`, `>=`.
63    pub condition: String,
64    /// Prometheus query window, e.g. `"5m"`. Must exceed the scrape interval
65    /// to avoid false positives from observation lag.
66    pub window: String,
67}
68
69/// One step in the ordered rollout sequence.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
72pub struct RolloutStep {
73    /// Mirror names to deploy in this step (e.g. `["yah-marketing-staging"]`).
74    pub mirrors: Vec<String>,
75    /// Seconds to observe gates after this step's deploy finishes.
76    /// Must exceed `gate.window` for all gates to avoid racing observation lag.
77    pub gate_window_seconds: u64,
78    /// Action when any gate fails. Defaults to `rollback-all` when absent.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub on_failure: Option<RolloutOnFailure>,
81}
82
83/// Failure action for a step.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
86#[serde(rename_all = "kebab-case")]
87pub enum RolloutOnFailure {
88    /// Rollback only this step's mirrors; earlier-promoted steps stay on the
89    /// new version.
90    RollbackStep,
91    /// Rollback all previously promoted steps back to the prior artifact.
92    RollbackAll,
93}
94
95impl RolloutOnFailure {
96    /// Return the effective `on_failure` action for a step, defaulting to
97    /// `RollbackAll` when the step doesn't declare one.
98    pub fn for_step(on_failure: Option<&Self>) -> Self {
99        on_failure.cloned().unwrap_or(Self::RollbackAll)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    const EXAMPLE_TOML: &str = r#"
108[rollout]
109strategy = "linear"
110window_seconds = 600
111
112[[rollout.gates]]
113metric = "http_5xx_rate"
114condition = "< 0.01"
115window = "5m"
116
117[[rollout.gates]]
118metric = "p95_latency_ms"
119condition = "< 200"
120window = "5m"
121
122[[rollout.steps]]
123mirrors = ["yah-marketing-staging"]
124gate_window_seconds = 600
125
126[[rollout.steps]]
127mirrors = ["yah-marketing-prod"]
128gate_window_seconds = 1800
129on_failure = "rollback-step"
130"#;
131
132    #[test]
133    fn round_trip_toml() {
134        // Parse from TOML, re-encode as JSON, parse back.
135        let file: RolloutFile = toml::from_str(EXAMPLE_TOML).expect("parse toml");
136        let policy = &file.rollout;
137
138        assert_eq!(policy.strategy, RolloutStrategy::Linear);
139        assert_eq!(policy.window_seconds, 600);
140        assert_eq!(policy.gates.len(), 2);
141        assert_eq!(policy.steps.len(), 2);
142
143        let step1 = &policy.steps[1];
144        assert_eq!(step1.mirrors, vec!["yah-marketing-prod".to_string()]);
145        assert_eq!(step1.gate_window_seconds, 1800);
146        assert_eq!(step1.on_failure, Some(RolloutOnFailure::RollbackStep));
147    }
148
149    #[test]
150    fn on_failure_default() {
151        assert_eq!(RolloutOnFailure::for_step(None), RolloutOnFailure::RollbackAll);
152        assert_eq!(
153            RolloutOnFailure::for_step(Some(&RolloutOnFailure::RollbackStep)),
154            RolloutOnFailure::RollbackStep
155        );
156    }
157}