Skip to main content

solti_model/domain/selector/
label.rs

1//! # Label selector
2//!
3//! [`LabelSelector`] follows Kubernetes label selector syntax and matching rules.
4//! Struct construction and direct deserialization do not validate requirements.
5//! Call [`LabelSelector::validate`] at an input boundary.
6
7use std::{fmt, str::FromStr};
8
9use serde::{Deserialize, Serialize};
10
11use super::{SelectorOperator, SelectorRequirement};
12use crate::{Labels, ModelError, ModelResult};
13
14/// Label selector for matching any labeled object.
15///
16/// Both `match_labels` and `match_expressions` are ANDed together.
17/// An empty selector matches every label set.
18///
19/// ## Example
20///
21/// ```
22/// use solti_model::{Labels, LabelSelector, SelectorRequirement};
23///
24/// let selector = LabelSelector {
25///     match_labels: {
26///         let mut labels = Labels::new();
27///         labels.insert("zone", "eu");
28///         labels
29///     },
30///     match_expressions: vec![SelectorRequirement::exists("gpu")],
31/// };
32///
33/// let mut labels = Labels::new();
34/// labels.insert("zone", "eu");
35/// labels.insert("gpu", "h100");
36///
37/// assert!(selector.matches(&labels));
38/// ```
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct LabelSelector {
43    /// Exact key-value matches.
44    #[serde(default, skip_serializing_if = "Labels::is_empty")]
45    pub match_labels: Labels,
46
47    /// Set-based requirements.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub match_expressions: Vec<SelectorRequirement>,
50}
51
52impl LabelSelector {
53    /// Creates an empty selector.
54    ///
55    /// An empty selector matches every label set.
56    ///
57    /// ## Example
58    ///
59    /// ```
60    /// use solti_model::{Labels, LabelSelector};
61    ///
62    /// assert!(LabelSelector::new().matches(&Labels::new()));
63    /// ```
64    #[inline]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Creates a selector from exact matches.
70    ///
71    /// ## Example
72    ///
73    /// ```
74    /// use solti_model::{Labels, LabelSelector};
75    ///
76    /// let mut labels = Labels::new();
77    /// labels.insert("zone", "eu");
78    ///
79    /// let selector = LabelSelector::from_labels(labels);
80    ///
81    /// let mut labels = Labels::new();
82    /// labels.insert("zone", "eu");
83    /// assert!(selector.matches(&labels));
84    /// ```
85    #[inline]
86    pub fn from_labels(labels: Labels) -> Self {
87        Self {
88            match_labels: labels,
89            match_expressions: vec![],
90        }
91    }
92
93    /// Creates a selector from expressions.
94    ///
95    /// ## Example
96    ///
97    /// ```
98    /// use solti_model::{Labels, LabelSelector, SelectorRequirement};
99    ///
100    /// let selector = LabelSelector::from_expressions(vec![
101    ///     SelectorRequirement::exists("gpu"),
102    /// ]);
103    ///
104    /// let mut labels = Labels::new();
105    /// labels.insert("gpu", "a100");
106    /// assert!(selector.matches(&labels));
107    /// ```
108    #[inline]
109    pub fn from_expressions(expr: Vec<SelectorRequirement>) -> Self {
110        Self {
111            match_labels: Labels::new(),
112            match_expressions: expr,
113        }
114    }
115
116    /// Returns whether the selector is empty.
117    #[inline]
118    pub fn is_empty(&self) -> bool {
119        self.match_labels.is_empty() && self.match_expressions.is_empty()
120    }
121
122    /// Validates the selector.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`ModelError::Invalid`] when a key, value, or requirement is invalid.
127    pub fn validate(&self) -> crate::ModelResult<()> {
128        self.match_labels.validate()?;
129        for requirement in &self.match_expressions {
130            requirement.validate()?;
131        }
132        Ok(())
133    }
134
135    /// Returns whether labels satisfy every requirement.
136    ///
137    /// `match_labels` and `match_expressions` are ANDed.
138    /// `NotIn` matches when the key is absent.
139    ///
140    /// ## Example
141    ///
142    /// ```
143    /// use solti_model::{Labels, LabelSelector, SelectorRequirement};
144    ///
145    /// let selector = LabelSelector::from_expressions(vec![
146    ///     SelectorRequirement::r#in("gpu", vec!["a100".into(), "h100".into()]),
147    /// ]);
148    ///
149    /// let mut labels = Labels::new();
150    /// labels.insert("gpu", "h100");
151    ///
152    /// assert!(selector.matches(&labels));
153    /// ```
154    pub fn matches(&self, labels: &Labels) -> bool {
155        for (key, expected) in &self.match_labels {
156            match labels.get(key) {
157                Some(actual) if actual == expected => {}
158                _ => return false,
159            }
160        }
161
162        for req in &self.match_expressions {
163            let value = labels.get(&req.key);
164            let ok = match req.operator {
165                SelectorOperator::In => match value {
166                    Some(v) => req.values.iter().any(|x| x == v),
167                    None => false,
168                },
169                SelectorOperator::NotIn => match value {
170                    Some(v) => !req.values.iter().any(|x| x == v),
171                    None => true,
172                },
173                SelectorOperator::Exists => value.is_some(),
174                SelectorOperator::DoesNotExist => value.is_none(),
175            };
176            if !ok {
177                return false;
178            }
179        }
180        true
181    }
182}
183
184impl FromStr for LabelSelector {
185    type Err = ModelError;
186
187    /// Parses Kubernetes label selector syntax.
188    ///
189    /// Supported requirements are `=`, `==`, `!=`, `in`, `notin`, key existence and `!key` non-existence. Top-level commas mean AND.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`ModelError::Invalid`] when the syntax or a requirement is invalid.
194    fn from_str(value: &str) -> ModelResult<Self> {
195        let value = trim_selector_whitespace(value);
196        if value.is_empty() {
197            return Ok(Self::new());
198        }
199
200        let requirements = split_requirements(value)?
201            .into_iter()
202            .map(parse_requirement)
203            .collect::<ModelResult<Vec<_>>>()?;
204        let selector = Self::from_expressions(requirements);
205        selector.validate()?;
206        Ok(selector)
207    }
208}
209
210impl fmt::Display for LabelSelector {
211    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212        let mut first = true;
213        let mut separator = |formatter: &mut fmt::Formatter<'_>| {
214            if first {
215                first = false;
216                Ok(())
217            } else {
218                formatter.write_str(",")
219            }
220        };
221
222        for (key, value) in &self.match_labels {
223            separator(formatter)?;
224            write!(formatter, "{key}={value}")?;
225        }
226        for requirement in &self.match_expressions {
227            separator(formatter)?;
228            match requirement.operator {
229                SelectorOperator::In => write!(
230                    formatter,
231                    "{} in ({})",
232                    requirement.key,
233                    requirement.values.join(",")
234                )?,
235                SelectorOperator::NotIn => write!(
236                    formatter,
237                    "{} notin ({})",
238                    requirement.key,
239                    requirement.values.join(",")
240                )?,
241                SelectorOperator::Exists => formatter.write_str(&requirement.key)?,
242                SelectorOperator::DoesNotExist => write!(formatter, "!{}", requirement.key)?,
243            }
244        }
245        Ok(())
246    }
247}
248
249fn split_requirements(value: &str) -> ModelResult<Vec<&str>> {
250    let mut result = Vec::new();
251    let mut start = 0;
252    let mut depth = 0_u8;
253
254    for (index, character) in value.char_indices() {
255        match character {
256            '(' => {
257                if depth > 0 {
258                    return Err(invalid_selector("nested parentheses are not allowed"));
259                }
260                depth = 1;
261            }
262            ')' => {
263                if depth == 0 {
264                    return Err(invalid_selector("unexpected closing parenthesis"));
265                }
266                depth = 0;
267            }
268            ',' if depth == 0 => {
269                let requirement = trim_selector_whitespace(&value[start..index]);
270                if requirement.is_empty() {
271                    return Err(invalid_selector("empty requirement"));
272                }
273                result.push(requirement);
274                start = index + character.len_utf8();
275            }
276            _ => {}
277        }
278    }
279
280    if depth != 0 {
281        return Err(invalid_selector("unclosed parenthesis"));
282    }
283    let requirement = trim_selector_whitespace(&value[start..]);
284    if requirement.is_empty() {
285        return Err(invalid_selector("empty requirement"));
286    }
287    result.push(requirement);
288    Ok(result)
289}
290
291fn parse_requirement(value: &str) -> ModelResult<SelectorRequirement> {
292    if let Some(key) = value.strip_prefix('!') {
293        let key = trim_selector_whitespace(key);
294        if key.is_empty() {
295            return Err(invalid_selector("missing key after `!`"));
296        }
297        return Ok(SelectorRequirement::does_not_exist(key));
298    }
299
300    if let Some(open) = value.find('(') {
301        let close = value
302            .rfind(')')
303            .ok_or_else(|| invalid_selector("unclosed parenthesis"))?;
304        if !trim_selector_whitespace(&value[close + 1..]).is_empty() {
305            return Err(invalid_selector(
306                "unexpected text after closing parenthesis",
307            ));
308        }
309
310        let head = trim_selector_whitespace_end(&value[..open]);
311        let (key, operator) = if let Some(key) = head.strip_suffix("notin")
312            && key
313                .as_bytes()
314                .last()
315                .is_some_and(|byte| is_selector_whitespace(*byte))
316        {
317            (trim_selector_whitespace_end(key), SelectorOperator::NotIn)
318        } else if let Some(key) = head.strip_suffix("in")
319            && key
320                .as_bytes()
321                .last()
322                .is_some_and(|byte| is_selector_whitespace(*byte))
323        {
324            (trim_selector_whitespace_end(key), SelectorOperator::In)
325        } else {
326            return Err(invalid_selector("expected `in` or `notin` before `(`"));
327        };
328        if key.is_empty() {
329            return Err(invalid_selector("missing key before set operator"));
330        }
331
332        let values = trim_selector_whitespace(&value[open + 1..close]);
333        let values: Vec<_> = values
334            .split(',')
335            .map(trim_selector_whitespace)
336            .map(|value| value.to_owned())
337            .collect();
338        return Ok(SelectorRequirement {
339            key: key.to_owned(),
340            operator,
341            values,
342        });
343    }
344
345    for (token, operator) in [
346        ("!=", SelectorOperator::NotIn),
347        ("==", SelectorOperator::In),
348        ("=", SelectorOperator::In),
349    ] {
350        if let Some((key, selected)) = value.split_once(token) {
351            let key = trim_selector_whitespace(key);
352            if key.is_empty() {
353                return Err(invalid_selector("missing key before equality operator"));
354            }
355            return Ok(SelectorRequirement {
356                key: key.to_owned(),
357                operator,
358                values: vec![trim_selector_whitespace(selected).to_owned()],
359            });
360        }
361    }
362
363    Ok(SelectorRequirement::exists(trim_selector_whitespace(value)))
364}
365
366fn trim_selector_whitespace(value: &str) -> &str {
367    trim_selector_whitespace_end(value.trim_start_matches(|character: char| {
368        character.is_ascii() && is_selector_whitespace(character as u8)
369    }))
370}
371
372fn trim_selector_whitespace_end(value: &str) -> &str {
373    value.trim_end_matches(|character: char| {
374        character.is_ascii() && is_selector_whitespace(character as u8)
375    })
376}
377
378const fn is_selector_whitespace(byte: u8) -> bool {
379    matches!(byte, b' ' | b'\t' | b'\r' | b'\n')
380}
381
382fn invalid_selector(message: &str) -> ModelError {
383    ModelError::Invalid(format!("invalid label selector: {message}").into())
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn labels(pairs: &[(&str, &str)]) -> Labels {
391        let mut labels = Labels::new();
392        for (key, value) in pairs {
393            labels.insert(*key, *value);
394        }
395        labels
396    }
397
398    #[test]
399    fn empty_and_exact_label_matching() {
400        let empty = LabelSelector::new();
401        assert!(empty.is_empty());
402        assert!(empty.matches(&labels(&[])));
403        assert!(empty.matches(&labels(&[("a", "b")])));
404
405        let selector = LabelSelector::from_labels(labels(&[("zone", "eu")]));
406        assert!(!selector.is_empty());
407        assert!(selector.matches(&labels(&[("zone", "eu"), ("extra", "x")])));
408        assert!(!selector.matches(&labels(&[("zone", "us")])));
409        assert!(!selector.matches(&labels(&[])));
410    }
411
412    #[test]
413    fn set_operators_follow_kubernetes_missing_key_semantics() {
414        let included = LabelSelector::from_expressions(vec![SelectorRequirement::r#in(
415            "gpu",
416            vec!["a100".into(), "h100".into()],
417        )]);
418        assert!(included.matches(&labels(&[("gpu", "a100")])));
419        assert!(included.matches(&labels(&[("gpu", "h100")])));
420        assert!(!included.matches(&labels(&[("gpu", "t4")])));
421        assert!(!included.matches(&labels(&[])));
422
423        let excluded = LabelSelector::from_expressions(vec![SelectorRequirement::not_in(
424            "tier",
425            vec!["dev".into()],
426        )]);
427        assert!(excluded.matches(&labels(&[("tier", "prod")])));
428        assert!(!excluded.matches(&labels(&[("tier", "dev")])));
429        assert!(excluded.matches(&labels(&[])));
430
431        assert!(
432            "tier!=frontend"
433                .parse::<LabelSelector>()
434                .unwrap()
435                .matches(&Labels::new())
436        );
437        assert!(
438            "tier notin (frontend)"
439                .parse::<LabelSelector>()
440                .unwrap()
441                .matches(&Labels::new())
442        );
443    }
444
445    #[test]
446    fn existence_operators_match_presence() {
447        let exists = LabelSelector::from_expressions(vec![SelectorRequirement::exists("gpu")]);
448        assert!(exists.matches(&labels(&[("gpu", "any")])));
449        assert!(!exists.matches(&labels(&[])));
450
451        let missing =
452            LabelSelector::from_expressions(vec![SelectorRequirement::does_not_exist("tainted")]);
453        assert!(missing.matches(&labels(&[])));
454        assert!(!missing.matches(&labels(&[("tainted", "true")])));
455    }
456
457    #[test]
458    fn labels_and_expressions_are_anded() {
459        let selector = LabelSelector {
460            match_labels: labels(&[("zone", "eu")]),
461            match_expressions: vec![SelectorRequirement::exists("gpu")],
462        };
463        assert!(selector.matches(&labels(&[("zone", "eu"), ("gpu", "a100")])));
464        assert!(!selector.matches(&labels(&[("zone", "us"), ("gpu", "a100")])));
465        assert!(!selector.matches(&labels(&[("zone", "eu")])));
466
467        let expressions = LabelSelector::from_expressions(vec![
468            SelectorRequirement::r#in("tier", vec!["prod".into(), "staging".into()]),
469            SelectorRequirement::does_not_exist("tainted"),
470        ]);
471        assert!(expressions.matches(&labels(&[("tier", "prod")])));
472        assert!(!expressions.matches(&labels(&[("tier", "prod"), ("tainted", "true")])));
473        assert!(!expressions.matches(&labels(&[("tier", "dev")])));
474    }
475
476    #[test]
477    fn serde_roundtrip_and_empty_shape_are_stable() {
478        let selector = LabelSelector {
479            match_labels: labels(&[("zone", "eu")]),
480            match_expressions: vec![SelectorRequirement::exists("gpu")],
481        };
482        let json = serde_json::to_string_pretty(&selector).unwrap();
483        let back: LabelSelector = serde_json::from_str(&json).unwrap();
484        assert_eq!(back, selector);
485
486        let empty = LabelSelector::new();
487        let json = serde_json::to_string(&empty).unwrap();
488        assert_eq!(json, "{}");
489        assert_eq!(serde_json::from_str::<LabelSelector>(&json).unwrap(), empty);
490    }
491
492    #[test]
493    fn validation_checks_labels_and_expressions() {
494        let mut invalid = Labels::new();
495        invalid.insert("bad key", "value");
496        assert!(LabelSelector::from_labels(invalid).validate().is_err());
497
498        let selector = LabelSelector::from_expressions(vec![SelectorRequirement::exists(
499            "example.io/capability",
500        )]);
501        selector.validate().unwrap();
502    }
503
504    #[test]
505    fn parser_and_display_use_kubernetes_selector_syntax() {
506        let selector: LabelSelector =
507            "environment=production,tier in (frontend,backend),track!=canary,!tainted,gpu"
508                .parse()
509                .unwrap();
510
511        assert!(selector.matches(&labels(&[
512            ("environment", "production"),
513            ("tier", "frontend"),
514            ("track", "stable"),
515            ("gpu", "h100"),
516        ])));
517        assert!(!selector.matches(&labels(&[
518            ("environment", "production"),
519            ("tier", "worker"),
520            ("track", "stable"),
521            ("gpu", "h100"),
522        ])));
523
524        let selector: LabelSelector = "release==stable".parse().unwrap();
525        assert!(selector.matches(&labels(&[("release", "stable")])));
526        assert!("".parse::<LabelSelector>().unwrap().is_empty());
527
528        let rendered = LabelSelector {
529            match_labels: labels(&[("environment", "production")]),
530            match_expressions: vec![
531                SelectorRequirement::r#in("tier", vec!["frontend".into(), "backend".into()]),
532                SelectorRequirement::does_not_exist("tainted"),
533            ],
534        };
535        assert_eq!(
536            rendered.to_string(),
537            "environment=production,tier in (frontend,backend),!tainted"
538        );
539    }
540
541    #[test]
542    fn empty_values_roundtrip_and_match_kubernetes_semantics() {
543        let selector: LabelSelector = "x in (foo,,baz),z notin ()".parse().unwrap();
544        assert!(selector.matches(&labels(&[("x", ""), ("z", "value")])));
545        assert!(!selector.matches(&labels(&[("x", "foo"), ("z", "")])));
546        assert!(
547            "key="
548                .parse::<LabelSelector>()
549                .unwrap()
550                .matches(&labels(&[("key", "")]))
551        );
552
553        for value in ["key=", "key in ()", "key in (foo,,baz)", "key notin ()"] {
554            let selector: LabelSelector = value.parse().unwrap();
555            let reparsed: LabelSelector = selector.to_string().parse().unwrap();
556            assert_eq!(reparsed, selector, "selector must round-trip: {value}");
557        }
558    }
559
560    #[test]
561    fn parser_accepts_ascii_whitespace_and_rejects_malformed_input() {
562        " \t\r\ntier\tin\n(frontend)\r\n"
563            .parse::<LabelSelector>()
564            .unwrap();
565
566        for value in [
567            "\u{00a0}tier in (frontend)",
568            "tier\u{00a0}in (frontend)",
569            "tier in (\u{00a0}frontend)",
570            ",environment=production",
571            "environment=production,",
572            "tier in (frontend",
573            "tier around (frontend)",
574            "!",
575            "bad key=value",
576            "tier in (front@end)",
577        ] {
578            assert!(
579                value.parse::<LabelSelector>().is_err(),
580                "selector must be rejected: {value:?}"
581            );
582        }
583    }
584}