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    /// R118-T5: hold each step until every node it names has reported a healthy
39    /// boot of the new artifact, and revert the rollout if any of them reports a
40    /// failed one.
41    ///
42    /// **Opt-in, and off by default, because the evidence has to exist.** A
43    /// cloud rollout's [`RolloutStep::mirrors`] are mirror names that nothing
44    /// will ever file a boot-health report for; turning this on for them would
45    /// stall every step until the rollout's [`Self::window_seconds`] ran out. A
46    /// rig rollout's `mirrors` are node names, and each of those nodes reports
47    /// its own RAUC verdict to its local yubaba
48    /// (`POST /v1/nodes/{node}/boot-health`).
49    ///
50    /// Turning it on switches the step gate from *fail-open* (a window elapses,
51    /// nothing is known, promote) to **fail-closed**: unknown is not healthy,
52    /// and a step whose nodes have not reported does not advance. That is the
53    /// same posture liveness takes in the noisetable camp — an unknown node is a
54    /// veto, never an assumed yes.
55    #[serde(default)]
56    pub require_node_health: bool,
57}
58
59/// Rollout strategy.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
62#[serde(rename_all = "kebab-case")]
63pub enum RolloutStrategy {
64    /// Deploy to each step's mirrors in sequence; promote only when all gates pass.
65    Linear,
66    /// Deploy to a configurable fraction of mirrors per step. Not implemented in v1.
67    CanaryFraction,
68}
69
70/// A single SLO gate evaluated after a step's gate window elapses.
71///
72/// All gates must pass before the rollout advances to the next step.
73/// A gate failure triggers the step's `on_failure` action.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
76pub struct RolloutGate {
77    /// Metric name. v1 yubaba supports `http_5xx_rate` and `p95_latency_ms`;
78    /// arbitrary PromQL expressions are also accepted.
79    pub metric: String,
80    /// Comparison condition, e.g. `"< 0.01"` or `"< 200"`.
81    /// Operators: `<`, `<=`, `>`, `>=`.
82    pub condition: String,
83    /// Prometheus query window, e.g. `"5m"`. Must exceed the scrape interval
84    /// to avoid false positives from observation lag.
85    pub window: String,
86}
87
88/// One step in the ordered rollout sequence.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
91pub struct RolloutStep {
92    /// Mirror names to deploy in this step (e.g. `["yah-marketing-staging"]`).
93    pub mirrors: Vec<String>,
94    /// Seconds to observe gates after this step's deploy finishes.
95    /// Must exceed `gate.window` for all gates to avoid racing observation lag.
96    pub gate_window_seconds: u64,
97    /// Action when any gate fails. Defaults to `rollback-all` when absent.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub on_failure: Option<RolloutOnFailure>,
100}
101
102/// Failure action for a step.
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104#[cfg_attr(feature = "json-schema", derive(JsonSchema))]
105#[serde(rename_all = "kebab-case")]
106pub enum RolloutOnFailure {
107    /// Rollback only this step's mirrors; earlier-promoted steps stay on the
108    /// new version.
109    RollbackStep,
110    /// Rollback all previously promoted steps back to the prior artifact.
111    RollbackAll,
112}
113
114impl RolloutOnFailure {
115    /// Return the effective `on_failure` action for a step, defaulting to
116    /// `RollbackAll` when the step doesn't declare one.
117    pub fn for_step(on_failure: Option<&Self>) -> Self {
118        on_failure.cloned().unwrap_or(Self::RollbackAll)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    const EXAMPLE_TOML: &str = r#"
127[rollout]
128strategy = "linear"
129window_seconds = 600
130
131[[rollout.gates]]
132metric = "http_5xx_rate"
133condition = "< 0.01"
134window = "5m"
135
136[[rollout.gates]]
137metric = "p95_latency_ms"
138condition = "< 200"
139window = "5m"
140
141[[rollout.steps]]
142mirrors = ["yah-marketing-staging"]
143gate_window_seconds = 600
144
145[[rollout.steps]]
146mirrors = ["yah-marketing-prod"]
147gate_window_seconds = 1800
148on_failure = "rollback-step"
149"#;
150
151    #[test]
152    fn round_trip_toml() {
153        // Parse from TOML, re-encode as JSON, parse back.
154        let file: RolloutFile = toml::from_str(EXAMPLE_TOML).expect("parse toml");
155        let policy = &file.rollout;
156
157        assert_eq!(policy.strategy, RolloutStrategy::Linear);
158        assert_eq!(policy.window_seconds, 600);
159        assert_eq!(policy.gates.len(), 2);
160        assert_eq!(policy.steps.len(), 2);
161
162        let step1 = &policy.steps[1];
163        assert_eq!(step1.mirrors, vec!["yah-marketing-prod".to_string()]);
164        assert_eq!(step1.gate_window_seconds, 1800);
165        assert_eq!(step1.on_failure, Some(RolloutOnFailure::RollbackStep));
166    }
167
168    #[test]
169    fn on_failure_default() {
170        assert_eq!(RolloutOnFailure::for_step(None), RolloutOnFailure::RollbackAll);
171        assert_eq!(
172            RolloutOnFailure::for_step(Some(&RolloutOnFailure::RollbackStep)),
173            RolloutOnFailure::RollbackStep
174        );
175    }
176}