Skip to main content

k8s_controller/
conditions.rs

1//! Maintaining the standard `status.conditions` of a resource.
2//!
3//! These functions operate on the [`Condition`] type that Kubernetes defines for this purpose, following the same rules
4//! as `k8s.io/apimachinery/pkg/api/meta.SetStatusCondition`:
5//!
6//! * A resource has at most one condition of each `type`.
7//! * `lastTransitionTime` records when the condition's `status` last
8//!   changed. It does not change when only the `reason`, `message`, or
9//!   `observedGeneration` do, so it answers "how long has this been
10//!   `False`?" rather than "when was this last written?".
11//! * `observedGeneration` records the `metadata.generation` of the resource
12//!   that the condition was determined from. A condition whose
13//!   `observedGeneration` is older than the resource's current generation
14//!   describes a spec that has since been changed, so should not be trusted
15//!   as describing the current one (see [`find_observed`]).
16//!
17//! A reconciler typically computes each condition from what it observed,
18//! applies them with [`set`], and writes the status back only if any of
19//! them changed:
20//!
21//! ```no_run
22//! # use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
23//! # use k8s_controller::conditions::{self, ConditionStatus, DesiredCondition};
24//! # struct Status { conditions: Vec<Condition> }
25//! # fn f(status: &mut Status, generation: Option<i64>, ready: bool) {
26//! let change = conditions::set(
27//!     &mut status.conditions,
28//!     if ready {
29//!         DesiredCondition::new("Ready", ConditionStatus::True, "DeploymentAvailable", "")
30//!     } else {
31//!         DesiredCondition::new(
32//!             "Ready",
33//!             ConditionStatus::False,
34//!             "DeploymentUnavailable",
35//!             "waiting for the deployment's pods to become ready",
36//!         )
37//!     }
38//!     .observed_generation(generation),
39//! );
40//! if change.is_changed() {
41//!     // write the status
42//! }
43//! # }
44//! ```
45
46use std::fmt::Display;
47use std::str::FromStr;
48
49use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time};
50use k8s_openapi::jiff::Timestamp;
51
52/// The `status` of a [`Condition`].
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
54pub enum ConditionStatus {
55    True,
56    False,
57    /// The controller cannot currently determine whether the condition
58    /// holds, for instance because it has not yet observed the resource it
59    /// depends on.
60    Unknown,
61}
62
63impl ConditionStatus {
64    /// The value of the `status` field for this status.
65    pub fn as_str(self) -> &'static str {
66        match self {
67            ConditionStatus::True => "True",
68            ConditionStatus::False => "False",
69            ConditionStatus::Unknown => "Unknown",
70        }
71    }
72}
73
74impl From<bool> for ConditionStatus {
75    fn from(value: bool) -> Self {
76        if value {
77            ConditionStatus::True
78        } else {
79            ConditionStatus::False
80        }
81    }
82}
83
84impl Display for ConditionStatus {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.write_str(self.as_str())
87    }
88}
89
90/// The error returned when parsing a string that is not `True`, `False`, or
91/// `Unknown` as a [`ConditionStatus`].
92#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
93#[error("invalid condition status {0:?}")]
94pub struct InvalidConditionStatus(String);
95
96impl FromStr for ConditionStatus {
97    type Err = InvalidConditionStatus;
98
99    fn from_str(s: &str) -> Result<Self, Self::Err> {
100        match s {
101            "True" => Ok(ConditionStatus::True),
102            "False" => Ok(ConditionStatus::False),
103            "Unknown" => Ok(ConditionStatus::Unknown),
104            _ => Err(InvalidConditionStatus(s.to_owned())),
105        }
106    }
107}
108
109/// A condition as a reconciler wants it to be, to be applied with [`set`].
110///
111/// This omits `lastTransitionTime`, which [`set`] maintains.
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct DesiredCondition {
114    /// The condition's type, in `CamelCase` or `foo.example.com/CamelCase`.
115    pub type_: String,
116    pub status: ConditionStatus,
117    /// A machine-readable `CamelCase` identifier for why the condition has
118    /// this status. Must not be empty: the API server rejects conditions
119    /// with an empty reason in resources that use the standard condition
120    /// schema.
121    pub reason: String,
122    /// A human-readable explanation, which may be empty.
123    pub message: String,
124    /// The `metadata.generation` of the resource that this condition was
125    /// determined from. See [`observed_generation`](Self::observed_generation).
126    pub observed_generation: Option<i64>,
127}
128
129impl DesiredCondition {
130    /// Creates a desired condition with no observed generation.
131    pub fn new(
132        type_: impl Into<String>,
133        status: ConditionStatus,
134        reason: impl Into<String>,
135        message: impl Into<String>,
136    ) -> Self {
137        Self {
138            type_: type_.into(),
139            status,
140            reason: reason.into(),
141            message: message.into(),
142            observed_generation: None,
143        }
144    }
145
146    /// Sets the generation this condition was determined from. This should
147    /// be the `metadata.generation` of the resource as it was passed to the
148    /// reconciler, not as it is when the status is written, since the spec
149    /// may have changed in between.
150    pub fn observed_generation(mut self, generation: Option<i64>) -> Self {
151        self.observed_generation = generation;
152        self
153    }
154}
155
156/// What [`set`] changed.
157#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
158#[must_use = "a status only needs to be written if its conditions changed"]
159pub enum ConditionChange {
160    /// The condition was already exactly as desired.
161    Unchanged,
162    /// The condition's `reason`, `message`, or `observedGeneration` changed,
163    /// but its `status` did not, so its `lastTransitionTime` was kept.
164    Updated,
165    /// The condition was added, or its `status` changed, and its
166    /// `lastTransitionTime` was set to the current time.
167    Transitioned,
168}
169
170impl ConditionChange {
171    /// Whether anything changed, and so whether the status needs writing.
172    pub fn is_changed(self) -> bool {
173        self != ConditionChange::Unchanged
174    }
175}
176
177/// Returns the condition of the given type, if present.
178pub fn find<'a>(conditions: &'a [Condition], type_: &str) -> Option<&'a Condition> {
179    conditions.iter().find(|c| c.type_ == type_)
180}
181
182/// Returns the condition of the given type, if present and determined from
183/// the given `generation` of its resource (which should be that resource's
184/// current `metadata.generation`).
185///
186/// Use this rather than [`find`] when reading the conditions of a resource
187/// that another controller maintains: until that controller has reconciled
188/// the latest change to the resource's spec, its conditions describe the
189/// previous spec.
190pub fn find_observed<'a>(
191    conditions: &'a [Condition],
192    type_: &str,
193    generation: Option<i64>,
194) -> Option<&'a Condition> {
195    find(conditions, type_).filter(|c| c.observed_generation == generation)
196}
197
198/// Sets the condition of `desired.type_` to `desired`, adding it if it is
199/// not already present, and returns what changed.
200///
201/// Its `lastTransitionTime` is set to the current time if the condition is
202/// added or its status changes, and otherwise kept.
203pub fn set(conditions: &mut Vec<Condition>, desired: DesiredCondition) -> ConditionChange {
204    set_at(conditions, desired, Timestamp::now())
205}
206
207fn set_at(
208    conditions: &mut Vec<Condition>,
209    desired: DesiredCondition,
210    now: Timestamp,
211) -> ConditionChange {
212    // Kubernetes serializes times at second precision, so a finer time would
213    // differ from the same condition read back from the API server.
214    let now = Time(Timestamp::from_second(now.as_second()).expect("in range"));
215    let DesiredCondition {
216        type_,
217        status,
218        reason,
219        message,
220        observed_generation,
221    } = desired;
222    let status = status.as_str();
223
224    let Some(existing) = conditions.iter_mut().find(|c| c.type_ == type_) else {
225        conditions.push(Condition {
226            type_,
227            status: status.to_owned(),
228            reason,
229            message,
230            observed_generation,
231            last_transition_time: now,
232        });
233        return ConditionChange::Transitioned;
234    };
235
236    if existing.status != status {
237        existing.status = status.to_owned();
238        existing.reason = reason;
239        existing.message = message;
240        existing.observed_generation = observed_generation;
241        existing.last_transition_time = now;
242        return ConditionChange::Transitioned;
243    }
244
245    if existing.reason == reason
246        && existing.message == message
247        && existing.observed_generation == observed_generation
248    {
249        return ConditionChange::Unchanged;
250    }
251    existing.reason = reason;
252    existing.message = message;
253    existing.observed_generation = observed_generation;
254    ConditionChange::Updated
255}
256
257/// Removes the condition of the given type, returning whether it was
258/// present.
259pub fn remove(conditions: &mut Vec<Condition>, type_: &str) -> bool {
260    let len = conditions.len();
261    conditions.retain(|c| c.type_ != type_);
262    conditions.len() != len
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn at(s: &str) -> Timestamp {
270        s.parse().unwrap()
271    }
272
273    fn ready(status: ConditionStatus, reason: &str) -> DesiredCondition {
274        DesiredCondition::new("Ready", status, reason, "").observed_generation(Some(1))
275    }
276
277    #[test]
278    fn adds_missing_condition() {
279        let mut conditions = vec![];
280        let change = set_at(
281            &mut conditions,
282            ready(ConditionStatus::False, "Starting"),
283            at("2026-09-23T00:00:00.75Z"),
284        );
285        assert_eq!(change, ConditionChange::Transitioned);
286        assert_eq!(
287            conditions,
288            [Condition {
289                type_: "Ready".to_owned(),
290                status: "False".to_owned(),
291                reason: "Starting".to_owned(),
292                message: String::new(),
293                observed_generation: Some(1),
294                last_transition_time: Time(at("2026-09-23T00:00:00Z")),
295            }]
296        );
297    }
298
299    #[test]
300    fn keeps_transition_time_unless_status_changes() {
301        let mut conditions = vec![];
302        let _ = set_at(
303            &mut conditions,
304            ready(ConditionStatus::False, "Starting"),
305            at("2026-09-23T00:00:00Z"),
306        );
307
308        let change = set_at(
309            &mut conditions,
310            ready(ConditionStatus::False, "Starting"),
311            at("2026-09-23T00:01:00Z"),
312        );
313        assert_eq!(change, ConditionChange::Unchanged);
314
315        let change = set_at(
316            &mut conditions,
317            ready(ConditionStatus::False, "WaitingForPods").observed_generation(Some(2)),
318            at("2026-09-23T00:02:00Z"),
319        );
320        assert_eq!(change, ConditionChange::Updated);
321        assert_eq!(conditions[0].reason, "WaitingForPods");
322        assert_eq!(conditions[0].observed_generation, Some(2));
323        assert_eq!(
324            conditions[0].last_transition_time,
325            Time(at("2026-09-23T00:00:00Z"))
326        );
327
328        let change = set_at(
329            &mut conditions,
330            ready(ConditionStatus::True, "Available"),
331            at("2026-09-23T00:03:00Z"),
332        );
333        assert_eq!(change, ConditionChange::Transitioned);
334        assert_eq!(conditions[0].status, "True");
335        assert_eq!(conditions[0].observed_generation, Some(1));
336        assert_eq!(
337            conditions[0].last_transition_time,
338            Time(at("2026-09-23T00:03:00Z"))
339        );
340    }
341
342    #[test]
343    fn leaves_other_conditions_alone() {
344        let mut conditions = vec![];
345        let t0 = at("2026-09-23T00:00:00Z");
346        let _ = set_at(
347            &mut conditions,
348            ready(ConditionStatus::True, "Available"),
349            t0,
350        );
351        let _ = set_at(
352            &mut conditions,
353            DesiredCondition::new("Degraded", ConditionStatus::False, "Healthy", ""),
354            t0,
355        );
356        let _ = set_at(
357            &mut conditions,
358            DesiredCondition::new("Degraded", ConditionStatus::True, "ReplicaLost", ""),
359            at("2026-09-23T00:05:00Z"),
360        );
361        assert_eq!(conditions.len(), 2);
362        assert_eq!(conditions[0].type_, "Ready");
363        assert_eq!(conditions[0].last_transition_time, Time(t0));
364        assert_eq!(conditions[1].status, "True");
365
366        assert!(remove(&mut conditions, "Degraded"));
367        assert!(!remove(&mut conditions, "Degraded"));
368        assert_eq!(conditions.len(), 1);
369    }
370
371    #[test]
372    fn find_observed_ignores_stale_conditions() {
373        let mut conditions = vec![];
374        let _ = set(&mut conditions, ready(ConditionStatus::True, "Available"));
375        assert!(find_observed(&conditions, "Ready", Some(1)).is_some());
376        assert!(find_observed(&conditions, "Ready", Some(2)).is_none());
377        assert!(find_observed(&conditions, "Missing", Some(1)).is_none());
378    }
379
380    #[test]
381    fn parses_status() {
382        for status in [
383            ConditionStatus::True,
384            ConditionStatus::False,
385            ConditionStatus::Unknown,
386        ] {
387            assert_eq!(status.as_str().parse(), Ok(status));
388        }
389        assert!("true".parse::<ConditionStatus>().is_err());
390    }
391}