1use 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#[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 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 pub fn reconciled() -> Self {
49 Self("Reconciled".into())
50 }
51
52 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89#[non_exhaustive]
90pub enum ConditionStatus {
91 Unknown,
93 True,
95 False,
97}
98
99#[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 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 pub fn condition_type(&self) -> &TaskConditionType {
164 &self.condition_type
165 }
166
167 pub fn status(&self) -> ConditionStatus {
169 self.status
170 }
171
172 pub fn observed_generation(&self) -> u64 {
174 self.observed_generation
175 }
176
177 pub fn last_transition_time(&self) -> SystemTime {
179 self.last_transition_time
180 }
181
182 pub fn reason(&self) -> &str {
184 &self.reason
185 }
186
187 pub fn message(&self) -> &str {
189 &self.message
190 }
191
192 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}