1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
54pub enum ConditionStatus {
55 True,
56 False,
57 Unknown,
61}
62
63impl ConditionStatus {
64 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#[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#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct DesiredCondition {
114 pub type_: String,
116 pub status: ConditionStatus,
117 pub reason: String,
122 pub message: String,
124 pub observed_generation: Option<i64>,
127}
128
129impl DesiredCondition {
130 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 pub fn observed_generation(mut self, generation: Option<i64>) -> Self {
151 self.observed_generation = generation;
152 self
153 }
154}
155
156#[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 Unchanged,
162 Updated,
165 Transitioned,
168}
169
170impl ConditionChange {
171 pub fn is_changed(self) -> bool {
173 self != ConditionChange::Unchanged
174 }
175}
176
177pub fn find<'a>(conditions: &'a [Condition], type_: &str) -> Option<&'a Condition> {
179 conditions.iter().find(|c| c.type_ == type_)
180}
181
182pub 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
198pub 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 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
257pub 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}