Skip to main content

solti_model/resource/
annotations.rs

1//! # Resource annotations
2//!
3//! [`Annotations`] stores key-sorted Kubernetes annotations.
4//! Insertion and direct deserialization do not validate entries.
5//! Call [`Annotations::validate`] at an input boundary.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{ModelError, ModelResult, validation};
12
13const ANNOTATIONS_MAX_TOTAL_BYTES: usize = 256 * 1024;
14
15/// Key-sorted, free-form resource metadata.
16///
17/// Annotations are kept distinct from labels because they are descriptive data, not selectors used for routing or filtering.
18#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[cfg_attr(
21    feature = "schema",
22    schemars(schema_with = "crate::schema::annotations")
23)]
24#[serde(transparent)]
25pub struct Annotations(BTreeMap<String, String>);
26
27impl Annotations {
28    /// Creates an empty annotation map.
29    #[inline]
30    pub fn new() -> Self {
31        Self(BTreeMap::new())
32    }
33
34    /// Returns the number of annotations.
35    #[inline]
36    pub fn len(&self) -> usize {
37        self.0.len()
38    }
39
40    /// Returns whether the map is empty.
41    #[inline]
42    pub fn is_empty(&self) -> bool {
43        self.0.is_empty()
44    }
45
46    /// Inserts or replaces an annotation.
47    #[inline]
48    pub fn insert<K, V>(&mut self, key: K, value: V) -> &mut Self
49    where
50        K: Into<String>,
51        V: Into<String>,
52    {
53        self.0.insert(key.into(), value.into());
54        self
55    }
56
57    /// Returns an annotation value.
58    #[inline]
59    pub fn get(&self, key: &str) -> Option<&str> {
60        self.0.get(key).map(String::as_str)
61    }
62
63    /// Iterates over annotations in key order.
64    #[inline]
65    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
66        self.0
67            .iter()
68            .map(|(key, value)| (key.as_str(), value.as_str()))
69    }
70
71    /// Validates annotation keys and total size.
72    ///
73    /// Annotation values remain arbitrary UTF-8 strings.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`ModelError::Invalid`] for an invalid key or when key and value bytes exceed 256 KiB in total.
78    pub fn validate(&self) -> ModelResult<()> {
79        let mut total_bytes = 0_usize;
80        for (key, value) in &self.0 {
81            validation::validate_qualified_name("annotation key", key)?;
82            total_bytes = total_bytes
83                .saturating_add(key.len())
84                .saturating_add(value.len());
85        }
86        if total_bytes > ANNOTATIONS_MAX_TOTAL_BYTES {
87            return Err(ModelError::Invalid(
88                format!(
89                    "annotations total size {total_bytes} bytes exceeds max {ANNOTATIONS_MAX_TOTAL_BYTES}"
90                )
91                .into(),
92            ));
93        }
94        Ok(())
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn serde_roundtrip_preserves_entries_and_arbitrary_values() {
104        let mut annotations = Annotations::new();
105        annotations.insert("example.io/note", "spaces, JSON: {\"ok\":true}");
106        annotations.validate().unwrap();
107
108        let json = serde_json::to_string(&annotations).unwrap();
109        let back: Annotations = serde_json::from_str(&json).unwrap();
110
111        assert_eq!(back, annotations);
112    }
113
114    #[test]
115    fn validation_rejects_invalid_keys_and_oversized_payloads() {
116        let mut annotations = Annotations::new();
117        annotations.insert("example.io/bad key", "value");
118        assert!(annotations.validate().is_err());
119
120        let mut annotations = Annotations::new();
121        annotations.insert("example.io/data", "x".repeat(ANNOTATIONS_MAX_TOTAL_BYTES));
122        assert!(annotations.validate().is_err());
123    }
124}