Skip to main content

solti_model/resource/
condition.rs

1//! # Task conditions
2//!
3//! [`TaskCondition`] records reconciliation state for one observed generation.
4
5use std::{fmt, time::SystemTime};
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use crate::{ModelError, ModelResult, validation};
10
11use super::metadata::time_serde;
12
13pub(crate) const CONDITION_TYPE_MAX_BYTES: usize = 316;
14pub(crate) const CONDITION_REASON_MAX_BYTES: usize = 1_024;
15const CONDITION_MESSAGE_MAX_BYTES: usize = 32_768;
16
17/// Stable and extensible type of condition reported for a [`Task`](crate::Task).
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[cfg_attr(
21    feature = "schema",
22    schemars(schema_with = "crate::schema::condition_type")
23)]
24pub struct TaskConditionType(String);
25
26impl TaskConditionType {
27    /// Creates a condition type.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`ModelError::Invalid`] when the value is too long or violates qualified-name rules.
32    pub fn new(value: impl Into<String>) -> ModelResult<Self> {
33        let value = value.into();
34        if value.len() > CONDITION_TYPE_MAX_BYTES {
35            return Err(ModelError::Invalid(
36                format!(
37                    "condition type length {} exceeds max {CONDITION_TYPE_MAX_BYTES}",
38                    value.len()
39                )
40                .into(),
41            ));
42        }
43        validation::validate_qualified_name("condition type", &value)?;
44        Ok(Self(value))
45    }
46
47    /// The controller condition for desired-state reconciliation.
48    pub fn reconciled() -> Self {
49        Self("Reconciled".into())
50    }
51
52    /// Returns the serialized condition type.
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56
57    pub(crate) fn is_reconciled(&self) -> bool {
58        self.0 == "Reconciled"
59    }
60}
61
62impl fmt::Display for TaskConditionType {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        formatter.write_str(self.as_str())
65    }
66}
67
68impl Serialize for TaskConditionType {
69    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
70    where
71        S: Serializer,
72    {
73        serializer.serialize_str(self.as_str())
74    }
75}
76
77impl<'de> Deserialize<'de> for TaskConditionType {
78    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
79    where
80        D: Deserializer<'de>,
81    {
82        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
83    }
84}
85
86/// Condition status.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89#[non_exhaustive]
90pub enum ConditionStatus {
91    /// The controller has not yet determined the outcome.
92    Unknown,
93    /// The condition currently holds.
94    True,
95    /// The condition currently does not hold.
96    False,
97}
98
99/// One observed condition for a Task resource.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
102#[cfg_attr(feature = "schema", schemars(!try_from, deny_unknown_fields))]
103#[serde(rename_all = "camelCase", try_from = "raw::TaskConditionRaw")]
104pub struct TaskCondition {
105    #[serde(rename = "type")]
106    condition_type: TaskConditionType,
107    status: ConditionStatus,
108    observed_generation: u64,
109    #[serde(with = "super::metadata::rfc3339_time_serde")]
110    #[cfg_attr(
111        feature = "schema",
112        schemars(schema_with = "crate::schema::rfc3339_time")
113    )]
114    last_transition_time: SystemTime,
115    #[cfg_attr(
116        feature = "schema",
117        schemars(schema_with = "crate::schema::condition_reason")
118    )]
119    reason: String,
120    #[cfg_attr(feature = "schema", schemars(length(max = 32_768)))]
121    message: String,
122}
123
124impl TaskCondition {
125    /// Creates a condition.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`ModelError::Invalid`] when the reason or message is invalid.
130    pub fn new(
131        condition_type: TaskConditionType,
132        status: ConditionStatus,
133        observed_generation: u64,
134        last_transition_time: SystemTime,
135        reason: impl Into<String>,
136        message: impl Into<String>,
137    ) -> ModelResult<Self> {
138        let condition = Self {
139            condition_type,
140            status,
141            observed_generation,
142            last_transition_time,
143            reason: reason.into(),
144            message: message.into(),
145        };
146        condition.validate()?;
147        Ok(condition)
148    }
149
150    pub(crate) fn reconciled_unknown(generation: u64) -> Self {
151        Self::new(
152            TaskConditionType::reconciled(),
153            ConditionStatus::Unknown,
154            generation,
155            time_serde::now(),
156            "ReconciliationScheduled",
157            "runtime reconciliation is scheduled",
158        )
159        .expect("built-in Reconciled condition is valid")
160    }
161
162    /// Stable condition type.
163    pub fn condition_type(&self) -> &TaskConditionType {
164        &self.condition_type
165    }
166
167    /// Current three-valued condition status.
168    pub fn status(&self) -> ConditionStatus {
169        self.status
170    }
171
172    /// Desired generation described by this condition.
173    pub fn observed_generation(&self) -> u64 {
174        self.observed_generation
175    }
176
177    /// Time when `status` last changed.
178    pub fn last_transition_time(&self) -> SystemTime {
179        self.last_transition_time
180    }
181
182    /// Stable machine-readable reason.
183    pub fn reason(&self) -> &str {
184        &self.reason
185    }
186
187    /// Human-readable diagnostic.
188    pub fn message(&self) -> &str {
189        &self.message
190    }
191
192    /// Returns the serialized condition fields.
193    pub fn into_parts(
194        self,
195    ) -> (
196        TaskConditionType,
197        ConditionStatus,
198        u64,
199        SystemTime,
200        String,
201        String,
202    ) {
203        (
204            self.condition_type,
205            self.status,
206            self.observed_generation,
207            self.last_transition_time,
208            self.reason,
209            self.message,
210        )
211    }
212
213    pub(crate) fn validate_reason_message(reason: &str, message: &str) -> ModelResult<()> {
214        let bytes = reason.as_bytes();
215        if bytes.is_empty() {
216            return Err(ModelError::Invalid(
217                "condition reason must not be empty".into(),
218            ));
219        }
220        if bytes.len() > CONDITION_REASON_MAX_BYTES {
221            return Err(ModelError::Invalid(
222                format!(
223                    "condition reason length {} exceeds max {CONDITION_REASON_MAX_BYTES}",
224                    bytes.len()
225                )
226                .into(),
227            ));
228        }
229        let valid_reason = bytes[0].is_ascii_alphabetic()
230            && (bytes[bytes.len() - 1].is_ascii_alphanumeric() || bytes[bytes.len() - 1] == b'_');
231        let valid_reason = valid_reason
232            && bytes
233                .iter()
234                .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b',' | b':'));
235        if !valid_reason {
236            return Err(ModelError::Invalid(
237                "condition reason must follow Kubernetes reason rules".into(),
238            ));
239        }
240        if message.len() > CONDITION_MESSAGE_MAX_BYTES {
241            return Err(ModelError::Invalid(
242                format!(
243                    "condition message length {} exceeds max {CONDITION_MESSAGE_MAX_BYTES}",
244                    message.len()
245                )
246                .into(),
247            ));
248        }
249        Ok(())
250    }
251
252    pub(crate) fn validate(&self) -> ModelResult<()> {
253        Self::validate_reason_message(&self.reason, &self.message)
254    }
255
256    pub(crate) fn transition(
257        &mut self,
258        status: ConditionStatus,
259        generation: u64,
260        reason: impl Into<String>,
261        message: impl Into<String>,
262    ) -> bool {
263        let reason = reason.into();
264        let message = message.into();
265        let changed = self.status != status
266            || self.observed_generation != generation
267            || self.reason != reason
268            || self.message != message;
269        if !changed {
270            return false;
271        }
272        if self.status != status {
273            self.last_transition_time = time_serde::now();
274        }
275        self.status = status;
276        self.observed_generation = generation;
277        self.reason = reason;
278        self.message = message;
279        true
280    }
281}
282
283mod raw {
284    use super::*;
285
286    #[derive(Deserialize)]
287    #[serde(rename_all = "camelCase", deny_unknown_fields)]
288    pub(super) struct TaskConditionRaw {
289        #[serde(rename = "type")]
290        condition_type: TaskConditionType,
291        status: ConditionStatus,
292        observed_generation: u64,
293        #[serde(with = "super::super::metadata::rfc3339_time_serde")]
294        last_transition_time: SystemTime,
295        reason: String,
296        message: String,
297    }
298
299    impl TryFrom<TaskConditionRaw> for TaskCondition {
300        type Error = ModelError;
301
302        fn try_from(raw: TaskConditionRaw) -> Result<Self, Self::Error> {
303            TaskCondition::new(
304                raw.condition_type,
305                raw.status,
306                raw.observed_generation,
307                raw.last_transition_time,
308                raw.reason,
309                raw.message,
310            )
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn condition_type_is_extensible_and_validated() {
321        let condition_type = TaskConditionType::new("example.io/Available").unwrap();
322        assert_eq!(condition_type.as_str(), "example.io/Available");
323
324        assert!(TaskConditionType::new("").is_err());
325        assert!(TaskConditionType::new("bad type").is_err());
326    }
327
328    #[test]
329    fn condition_rejects_invalid_kubernetes_reason() {
330        let result = TaskCondition::new(
331            TaskConditionType::reconciled(),
332            ConditionStatus::False,
333            1,
334            SystemTime::UNIX_EPOCH,
335            "invalid-reason",
336            "diagnostic",
337        );
338
339        assert!(result.is_err());
340    }
341}