Skip to main content

release_kit/plan/
readiness.rs

1//! The readiness policy: what a plan's preconditions require, what each
2//! one was found to be, and the one derivation that turns the two into a
3//! verdict.
4//!
5//! A gap is honest and is not permission. Every precondition carries a
6//! requirement, and the plan's readiness is the worst precondition: a
7//! required one not satisfied blocks, a decision-required one not yet
8//! answered asks, and an advisory one never counts. Apply proceeds on
9//! `ready` alone, and there is no flag that makes a gap into a pass.
10
11use serde::{Deserialize, Serialize};
12
13/// What a precondition demands of the plan.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum Requirement {
17    /// Reported, never blocking: a fact the operator wants in front of
18    /// them and nothing in the operations depends on.
19    Advisory,
20    /// The operator owns the answer: the plan waits until a decision with
21    /// the matching id is selected.
22    DecisionRequired,
23    /// Nothing proceeds until it holds.
24    Required,
25}
26
27impl Requirement {
28    /// The wire form, identical to the serde rendering.
29    #[must_use]
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::Advisory => "advisory",
33            Self::DecisionRequired => "decision-required",
34            Self::Required => "required",
35        }
36    }
37}
38
39/// What one precondition was found to be.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(tag = "state", rename_all = "kebab-case")]
42pub enum Evaluation {
43    /// The condition holds.
44    Satisfied,
45    /// Nothing could say either way, and the reason names why.
46    NotObserved {
47        /// Why the observation is missing.
48        reason: String,
49    },
50    /// The condition does not hold, and the reason names what was found.
51    Unsatisfied {
52        /// What was found instead.
53        reason: String,
54    },
55}
56
57impl Evaluation {
58    /// The state word, for the fingerprint's canonical form.
59    #[must_use]
60    pub const fn word(&self) -> &'static str {
61        match self {
62            Self::Satisfied => "satisfied",
63            Self::NotObserved { .. } => "not-observed",
64            Self::Unsatisfied { .. } => "unsatisfied",
65        }
66    }
67
68    /// Whether the condition holds.
69    #[must_use]
70    pub const fn holds(&self) -> bool {
71        matches!(self, Self::Satisfied)
72    }
73}
74
75/// One precondition of a plan: a stable id, what it requires, what it
76/// was found to be, and the decision that resolves it where one does.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Precondition {
79    /// A stable id, the same across re-plans of the same target.
80    pub id: String,
81    /// The policy on this precondition.
82    pub requirement: Requirement,
83    /// What was found.
84    pub evaluation: Evaluation,
85    /// The decision whose selected answer satisfies this precondition,
86    /// where the requirement is decision-required.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub decision: Option<String>,
89    /// The evidence this evaluation rests on.
90    pub evidence_refs: Vec<String>,
91}
92
93/// Whether the plan may be applied.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum Readiness {
97    /// Every required precondition holds and every decision is answered.
98    Ready,
99    /// A decision the operator owns is not yet selected.
100    NeedsDecision,
101    /// A required precondition does not hold.
102    Blocked,
103}
104
105impl Readiness {
106    /// The wire form, identical to the serde rendering.
107    #[must_use]
108    pub const fn as_str(self) -> &'static str {
109        match self {
110            Self::Ready => "ready",
111            Self::NeedsDecision => "needs-decision",
112            Self::Blocked => "blocked",
113        }
114    }
115}
116
117/// The one derivation: the worst precondition decides.
118///
119/// A required precondition that does not hold, observed or not, blocks.
120/// A decision-required one that does not hold asks. An advisory one is
121/// reported and never counts.
122#[must_use]
123pub fn derive(preconditions: &[Precondition]) -> Readiness {
124    let mut readiness = Readiness::Ready;
125    for precondition in preconditions {
126        if precondition.evaluation.holds() {
127            continue;
128        }
129        match precondition.requirement {
130            Requirement::Required => return Readiness::Blocked,
131            Requirement::DecisionRequired => readiness = Readiness::NeedsDecision,
132            Requirement::Advisory => {}
133        }
134    }
135    readiness
136}
137
138#[cfg(test)]
139mod tests {
140    use super::{Evaluation, Precondition, Readiness, Requirement, derive};
141
142    fn precondition(requirement: Requirement, evaluation: Evaluation) -> Precondition {
143        Precondition {
144            id: "one".into(),
145            requirement,
146            evaluation,
147            decision: None,
148            evidence_refs: Vec::new(),
149        }
150    }
151
152    /// The table: three requirements against three evaluations, and the
153    /// worst one wins when several stand together.
154    #[test]
155    fn readiness_is_the_worst_precondition() {
156        let not_observed = || Evaluation::NotObserved {
157            reason: "unread".into(),
158        };
159        let unsatisfied = || Evaluation::Unsatisfied {
160            reason: "found otherwise".into(),
161        };
162        let table: Vec<(Requirement, Evaluation, Readiness)> = vec![
163            (
164                Requirement::Advisory,
165                Evaluation::Satisfied,
166                Readiness::Ready,
167            ),
168            (Requirement::Advisory, not_observed(), Readiness::Ready),
169            (Requirement::Advisory, unsatisfied(), Readiness::Ready),
170            (
171                Requirement::DecisionRequired,
172                Evaluation::Satisfied,
173                Readiness::Ready,
174            ),
175            (
176                Requirement::DecisionRequired,
177                not_observed(),
178                Readiness::NeedsDecision,
179            ),
180            (
181                Requirement::DecisionRequired,
182                unsatisfied(),
183                Readiness::NeedsDecision,
184            ),
185            (
186                Requirement::Required,
187                Evaluation::Satisfied,
188                Readiness::Ready,
189            ),
190            (Requirement::Required, not_observed(), Readiness::Blocked),
191            (Requirement::Required, unsatisfied(), Readiness::Blocked),
192        ];
193        for (requirement, evaluation, expected) in table {
194            let got = derive(&[precondition(requirement, evaluation.clone())]);
195            assert_eq!(got, expected, "{requirement:?} {evaluation:?}");
196        }
197        assert_eq!(derive(&[]), Readiness::Ready);
198        let mixed = [
199            precondition(Requirement::Advisory, unsatisfied()),
200            precondition(Requirement::DecisionRequired, not_observed()),
201            precondition(Requirement::Required, Evaluation::Satisfied),
202        ];
203        assert_eq!(derive(&mixed), Readiness::NeedsDecision);
204        let blocked = [
205            precondition(Requirement::DecisionRequired, not_observed()),
206            precondition(Requirement::Required, unsatisfied()),
207        ];
208        assert_eq!(derive(&blocked), Readiness::Blocked);
209    }
210
211    #[test]
212    fn the_words_are_the_wire_form() {
213        for (requirement, word) in [
214            (Requirement::Advisory, "advisory"),
215            (Requirement::DecisionRequired, "decision-required"),
216            (Requirement::Required, "required"),
217        ] {
218            assert_eq!(requirement.as_str(), word);
219            assert_eq!(
220                serde_json::to_string(&requirement).expect("serializes"),
221                format!("\"{word}\"")
222            );
223        }
224        for (readiness, word) in [
225            (Readiness::Ready, "ready"),
226            (Readiness::NeedsDecision, "needs-decision"),
227            (Readiness::Blocked, "blocked"),
228        ] {
229            assert_eq!(readiness.as_str(), word);
230            assert_eq!(
231                serde_json::to_string(&readiness).expect("serializes"),
232                format!("\"{word}\"")
233            );
234        }
235        assert_eq!(
236            serde_json::to_string(&Evaluation::NotObserved {
237                reason: "unread".into()
238            })
239            .expect("serializes"),
240            r#"{"state":"not-observed","reason":"unread"}"#
241        );
242    }
243}