Skip to main content

solti_model/resource/
metadata.rs

1//! # Resource metadata
2//!
3//! [`ObjectMeta`] contains server-owned identity and version fields.
4//! It also contains caller-owned labels and annotations.
5
6use std::{fmt, time::SystemTime};
7
8use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
9use serde::{Deserialize, Deserializer, Serialize};
10
11use crate::{Annotations, Labels, ModelError, ModelResult, TaskId};
12
13const UID_ENTROPY_BYTES: usize = 16;
14
15/// Opaque, server-assigned identity of one resource incarnation.
16///
17/// A UID changes when a resource is deleted and recreated with the same name.
18#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[cfg_attr(feature = "schema", schemars(schema_with = "crate::schema::uid"))]
21#[serde(transparent)]
22pub struct Uid(String);
23
24impl Uid {
25    /// Wraps an existing UID.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`ModelError::Invalid`] when the value is empty.
30    pub fn new(value: impl Into<String>) -> ModelResult<Self> {
31        let value = value.into();
32        if value.trim().is_empty() {
33            return Err(ModelError::Invalid("uid must not be empty".into()));
34        }
35        Ok(Self(value))
36    }
37
38    /// Generates a UID from 128 bits of operating system entropy.
39    ///
40    /// # Errors
41    ///
42    /// Returns [`ModelError::Invalid`] when the entropy source is unavailable.
43    pub fn generate() -> ModelResult<Self> {
44        let mut bytes = [0_u8; UID_ENTROPY_BYTES];
45        getrandom::fill(&mut bytes).map_err(|error| {
46            ModelError::Invalid(format!("OS entropy source unavailable: {error}").into())
47        })?;
48        Ok(Self(URL_SAFE_NO_PAD.encode(bytes)))
49    }
50
51    /// Returns the opaque UID value.
52    #[inline]
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl fmt::Display for Uid {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        formatter.write_str(&self.0)
61    }
62}
63
64impl<'de> Deserialize<'de> for Uid {
65    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
66    where
67        D: Deserializer<'de>,
68    {
69        let value = String::deserialize(deserializer)?;
70        Self::new(value).map_err(serde::de::Error::custom)
71    }
72}
73
74/// Identity, concurrency version, generation and user metadata for a task.
75///
76/// `name` is the stable resource address.
77/// `uid` identifies one incarnation.
78/// `resource_version` is assigned by the state store and remains opaque.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82pub struct ObjectMeta {
83    name: TaskId,
84    uid: Uid,
85    resource_version: String,
86    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
87    generation: u64,
88    #[serde(with = "rfc3339_time_serde")]
89    #[cfg_attr(
90        feature = "schema",
91        schemars(schema_with = "crate::schema::rfc3339_time")
92    )]
93    creation_timestamp: SystemTime,
94    #[serde(default, skip_serializing_if = "Labels::is_empty")]
95    labels: Labels,
96    #[serde(default, skip_serializing_if = "Annotations::is_empty")]
97    annotations: Annotations,
98}
99
100impl ObjectMeta {
101    /// Creates metadata and generates a UID.
102    ///
103    /// The initial generation is `1`.
104    /// The state store assigns the first resource version through [`Self::set_resource_version`].
105    ///
106    /// # Errors
107    ///
108    /// Returns [`ModelError::Invalid`] when the name is invalid or UID generation fails.
109    pub fn new(name: TaskId) -> ModelResult<Self> {
110        Self::with_uid(name, Uid::generate()?)
111    }
112
113    /// Creates metadata with an existing UID.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`ModelError::Invalid`] when the name is invalid.
118    pub fn with_uid(name: TaskId, uid: Uid) -> ModelResult<Self> {
119        name.validate_format()?;
120        Ok(Self {
121            name,
122            uid,
123            resource_version: String::new(),
124            generation: 1,
125            creation_timestamp: time_serde::now(),
126            labels: Labels::new(),
127            annotations: Annotations::new(),
128        })
129    }
130
131    /// Stable resource name.
132    #[inline]
133    pub fn name(&self) -> &TaskId {
134        &self.name
135    }
136
137    /// Server-assigned identity of this resource incarnation.
138    #[inline]
139    pub fn uid(&self) -> &Uid {
140        &self.uid
141    }
142
143    /// Opaque state-store version used for optimistic concurrency.
144    #[inline]
145    pub fn resource_version(&self) -> &str {
146        &self.resource_version
147    }
148
149    /// Desired-state generation.
150    #[inline]
151    pub fn generation(&self) -> u64 {
152        self.generation
153    }
154
155    /// Resource creation timestamp.
156    #[inline]
157    pub fn creation_timestamp(&self) -> SystemTime {
158        self.creation_timestamp
159    }
160
161    /// Selector metadata.
162    #[inline]
163    pub fn labels(&self) -> &Labels {
164        &self.labels
165    }
166
167    /// Free-form metadata.
168    #[inline]
169    pub fn annotations(&self) -> &Annotations {
170        &self.annotations
171    }
172
173    /// Assigns an opaque state-store version.
174    ///
175    /// The value is stored verbatim and is never parsed by the model.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`ModelError::Invalid`] when the value is empty.
180    pub fn set_resource_version(&mut self, resource_version: impl Into<String>) -> ModelResult<()> {
181        let resource_version = resource_version.into();
182        if resource_version.trim().is_empty() {
183            return Err(ModelError::Invalid(
184                "resourceVersion must not be empty when assigned".into(),
185            ));
186        }
187        self.resource_version = resource_version;
188        Ok(())
189    }
190
191    pub(crate) fn apply_metadata(&mut self, labels: Labels, annotations: Annotations) -> bool {
192        if self.labels == labels && self.annotations == annotations {
193            return false;
194        }
195        self.labels = labels;
196        self.annotations = annotations;
197        true
198    }
199
200    pub(crate) fn bump_generation(&mut self) {
201        self.generation = self.generation.saturating_add(1);
202    }
203}
204
205pub(crate) mod time_serde {
206    use serde::{Deserialize, Deserializer, Serialize, Serializer};
207    use std::time::{SystemTime, UNIX_EPOCH};
208
209    pub(crate) fn now() -> SystemTime {
210        let duration = SystemTime::now()
211            .duration_since(UNIX_EPOCH)
212            .unwrap_or_default();
213        UNIX_EPOCH + std::time::Duration::from_millis(duration.as_millis() as u64)
214    }
215
216    pub(crate) fn serialize<S>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
217    where
218        S: Serializer,
219    {
220        let since_epoch = time
221            .duration_since(UNIX_EPOCH)
222            .map_err(serde::ser::Error::custom)?;
223        let ms = since_epoch.as_secs() * 1_000 + u64::from(since_epoch.subsec_millis());
224        ms.serialize(serializer)
225    }
226
227    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
228    where
229        D: Deserializer<'de>,
230    {
231        let ms = u64::deserialize(deserializer)?;
232        UNIX_EPOCH
233            .checked_add(std::time::Duration::from_millis(ms))
234            .ok_or_else(|| <D::Error as serde::de::Error>::custom("timestamp out of range"))
235    }
236}
237
238pub(crate) mod rfc3339_time_serde {
239    use serde::{Deserialize, Deserializer, Serializer};
240    use std::time::SystemTime;
241    use time::{OffsetDateTime, format_description::well_known::Rfc3339};
242
243    pub(crate) fn serialize<S>(timestamp: &SystemTime, serializer: S) -> Result<S::Ok, S::Error>
244    where
245        S: Serializer,
246    {
247        OffsetDateTime::from(*timestamp)
248            .format(&Rfc3339)
249            .map_err(serde::ser::Error::custom)
250            .and_then(|value| serializer.serialize_str(&value))
251    }
252
253    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<SystemTime, D::Error>
254    where
255        D: Deserializer<'de>,
256    {
257        let value = String::deserialize(deserializer)?;
258        let timestamp =
259            OffsetDateTime::parse(&value, &Rfc3339).map_err(serde::de::Error::custom)?;
260        Ok(SystemTime::from(timestamp))
261    }
262
263    pub(crate) mod option {
264        use serde::{Deserialize, Deserializer, Serializer};
265        use std::time::SystemTime;
266
267        pub(crate) fn serialize<S>(
268            timestamp: &Option<SystemTime>,
269            serializer: S,
270        ) -> Result<S::Ok, S::Error>
271        where
272            S: Serializer,
273        {
274            match timestamp {
275                Some(timestamp) => super::serialize(timestamp, serializer),
276                None => serializer.serialize_none(),
277            }
278        }
279
280        pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<SystemTime>, D::Error>
281        where
282            D: Deserializer<'de>,
283        {
284            Option::<String>::deserialize(deserializer)?
285                .map(|value| {
286                    time::OffsetDateTime::parse(
287                        &value,
288                        &time::format_description::well_known::Rfc3339,
289                    )
290                    .map(SystemTime::from)
291                    .map_err(serde::de::Error::custom)
292                })
293                .transpose()
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn new_sets_server_identity_and_generation() {
304        let meta = ObjectMeta::new(TaskId::new("task-a").unwrap()).unwrap();
305
306        assert_eq!(meta.name(), "task-a");
307        assert!(!meta.uid().as_str().is_empty());
308        assert_eq!(meta.generation(), 1);
309        assert_eq!(meta.resource_version(), "");
310    }
311
312    #[test]
313    fn server_metadata_uses_kubernetes_fields_and_opaque_resource_version() {
314        let mut meta = ObjectMeta::new(TaskId::new("task-a").unwrap()).unwrap();
315        meta.set_resource_version("store/revision:0021").unwrap();
316
317        assert_eq!(meta.resource_version(), "store/revision:0021");
318        assert!(serde_json::from_str::<Uid>(r#"""#).is_err());
319
320        let json = serde_json::to_value(&meta).unwrap();
321        assert_eq!(json["name"], "task-a");
322        assert_eq!(json["resourceVersion"], "store/revision:0021");
323        assert_eq!(json["generation"], 1);
324        assert!(json["creationTimestamp"].is_string());
325        assert!(json.get("uid").is_some());
326    }
327
328    #[test]
329    fn creation_timestamp_uses_rfc3339_milliseconds_and_rejects_unix_numbers() {
330        let mut meta = ObjectMeta::new(TaskId::new("task-a").unwrap()).unwrap();
331        meta.set_resource_version("1").unwrap();
332        let mut json = serde_json::to_value(meta).unwrap();
333        json["creationTimestamp"] = serde_json::json!("2025-01-02T03:04:05.678Z");
334
335        let back: ObjectMeta = serde_json::from_value(json).unwrap();
336        let serialized = serde_json::to_value(back).unwrap();
337
338        assert_eq!(serialized["creationTimestamp"], "2025-01-02T03:04:05.678Z");
339
340        let meta = ObjectMeta::new(TaskId::new("task-a").unwrap()).unwrap();
341        let mut json = serde_json::to_value(meta).unwrap();
342        json["creationTimestamp"] = serde_json::json!(1_735_786_800_000_u64);
343
344        assert!(serde_json::from_value::<ObjectMeta>(json).is_err());
345    }
346}