Skip to main content

net/adapter/net/subnet/
assignment.rs

1//! Label-based subnet assignment.
2//!
3//! Nodes belong to subnets by their capability tags, not static configuration.
4//! A `SubnetPolicy` maps tag patterns to hierarchy levels, deriving a `SubnetId`
5//! from a node's `CapabilitySet`.
6
7use std::collections::HashMap;
8
9use super::error::SubnetError;
10use super::id::SubnetId;
11use crate::adapter::net::behavior::capability::CapabilitySet;
12
13/// Policy for assigning nodes to subnets based on capability tags.
14///
15/// Rules are evaluated in order. Each rule maps a tag prefix to a hierarchy
16/// level and provides a value map for the tag's value.
17///
18/// Example: a node with tags `["region:us-west", "fleet:alpha"]` and rules:
19/// - `SubnetRule { tag_prefix: "region:", level: 0, values: {"us-west": 1} }`
20/// - `SubnetRule { tag_prefix: "fleet:", level: 1, values: {"alpha": 2} }`
21///
22/// Would get `SubnetId::new(&[1, 2])`.
23///
24/// # Semantics (rule precedence and matching contract)
25///
26/// Pinned by unit tests in this module — changes here are
27/// behavioral breaks for operators configuring subnets:
28///
29/// 1. **Rule order is declaration order.** `assign()` walks
30///    `rules` in the order passed to `add_rule()`. Two rules
31///    targeting the *same* `level` with overlapping values
32///    resolve as *later-rule-wins*: the earlier rule may write
33///    the level byte first, but a subsequent match at the same
34///    level overwrites it.
35/// 2. **First tag wins per rule.** Inside one rule, the first
36///    capability tag whose stripped suffix is present in `values`
37///    wins — subsequent tags matching the same rule are ignored.
38/// 3. **No partial-prefix match on values.** `tag_prefix` is
39///    stripped by [`str::strip_prefix`]; the remaining value is
40///    then looked up by *exact* string equality against `values`.
41///    A rule `region:` matching on `"us"` will **not** match the
42///    tag `region:us:extra` (the stripped suffix `"us:extra"` is
43///    not in the values map).
44/// 4. **Unmatched levels stay zero.** Levels with no rule (or a
45///    rule that failed to match) remain `0`, which [`SubnetId`]
46///    interprets as "no restriction at this level".
47#[derive(Debug, Clone)]
48pub struct SubnetPolicy {
49    rules: Vec<SubnetRule>,
50}
51
52/// A single rule mapping a tag pattern to a hierarchy level.
53#[derive(Debug, Clone)]
54pub struct SubnetRule {
55    /// Tag prefix to match (e.g., "region:").
56    pub tag_prefix: String,
57    /// Which hierarchy level this tag fills (0-3).
58    pub level: u8,
59    /// Map from tag value to level value (e.g., "us-west" -> 1).
60    pub values: HashMap<String, u8>,
61}
62
63impl SubnetPolicy {
64    /// Create an empty policy (all nodes get SubnetId::GLOBAL).
65    pub fn new() -> Self {
66        Self { rules: Vec::new() }
67    }
68
69    /// Add a rule to the policy.
70    ///
71    /// # Panics
72    /// Panics if the rule's level is >= 4. For untrusted input
73    /// (config files, FFI, JSON) prefer [`Self::try_add_rule`],
74    /// which returns a [`SubnetError`] instead of panicking.
75    pub fn add_rule(self, rule: SubnetRule) -> Self {
76        self.try_add_rule(rule)
77            .expect("SubnetPolicy::add_rule: invalid rule (use try_add_rule for fallible)")
78    }
79
80    /// Fallible variant of [`Self::add_rule`].
81    ///
82    /// Pre-existing `add_rule` panics on `rule.level >= 4`.
83    /// Subnet policies typically come from config / FFI / JSON and
84    /// a malformed entry should surface as a recoverable error
85    /// rather than crashing the daemon loader.
86    pub fn try_add_rule(mut self, rule: SubnetRule) -> Result<Self, SubnetError> {
87        if rule.level >= 4 {
88            return Err(SubnetError::LevelOutOfRange { got: rule.level });
89        }
90        self.rules.push(rule);
91        Ok(self)
92    }
93
94    /// Assign a subnet ID to a node based on its capability tags.
95    ///
96    /// Evaluates all rules against the node's tags. Unmatched levels
97    /// remain zero (meaning "no restriction at that level").
98    pub fn assign(&self, caps: &CapabilitySet) -> SubnetId {
99        let mut levels = [0u8; 4];
100
101        // Phase A.5.N.2: caps.tags is HashSet<Tag>; render each tag
102        // to its wire-form string AND sort lexicographically so
103        // the first-match-wins resolution is deterministic across
104        // runs (HashSet iteration order is unspecified).
105        let mut tag_strings: Vec<String> = caps.tags.iter().map(|t| t.to_string()).collect();
106        tag_strings.sort();
107
108        for rule in &self.rules {
109            for s in &tag_strings {
110                if let Some(value) = s.strip_prefix(&rule.tag_prefix) {
111                    if let Some(&level_value) = rule.values.get(value) {
112                        levels[rule.level as usize] = level_value;
113                        break; // first match wins for this rule
114                    }
115                }
116            }
117        }
118
119        SubnetId::new(&levels)
120    }
121}
122
123impl Default for SubnetPolicy {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl SubnetRule {
130    /// Create a new rule.
131    pub fn new(tag_prefix: impl Into<String>, level: u8) -> Self {
132        Self {
133            tag_prefix: tag_prefix.into(),
134            level,
135            values: HashMap::new(),
136        }
137    }
138
139    /// Map a tag value to a level value.
140    ///
141    /// # Panics
142    /// Panics if `level_value` is 0 (reserved for "unmatched /
143    /// no restriction"). For untrusted input prefer
144    /// [`Self::try_map`].
145    pub fn map(self, tag_value: impl Into<String>, level_value: u8) -> Self {
146        self.try_map(tag_value, level_value)
147            .expect("SubnetRule::map: level_value 0 is reserved (use try_map for fallible)")
148    }
149
150    /// Fallible variant of [`Self::map`].
151    ///
152    /// Pre-existing `map` panics on `level_value == 0`.
153    /// Returns [`SubnetError::LevelValueReserved`] instead.
154    pub fn try_map(
155        mut self,
156        tag_value: impl Into<String>,
157        level_value: u8,
158    ) -> Result<Self, SubnetError> {
159        if level_value == 0 {
160            return Err(SubnetError::LevelValueReserved);
161        }
162        self.values.insert(tag_value.into(), level_value);
163        Ok(self)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::adapter::net::behavior::capability::CapabilitySet;
171
172    fn caps_with_tags(tags: &[&str]) -> CapabilitySet {
173        let mut caps = CapabilitySet::new();
174        for tag in tags {
175            caps = caps.add_tag(*tag);
176        }
177        caps
178    }
179
180    #[test]
181    fn test_empty_policy() {
182        let policy = SubnetPolicy::new();
183        let caps = caps_with_tags(&["region:us-west"]);
184        assert_eq!(policy.assign(&caps), SubnetId::GLOBAL);
185    }
186
187    #[test]
188    fn test_single_level() {
189        let policy = SubnetPolicy::new().add_rule(
190            SubnetRule::new("region:", 0)
191                .map("us-west", 1)
192                .map("eu-central", 2),
193        );
194
195        let caps = caps_with_tags(&["region:us-west"]);
196        assert_eq!(policy.assign(&caps), SubnetId::new(&[1]));
197
198        let caps = caps_with_tags(&["region:eu-central"]);
199        assert_eq!(policy.assign(&caps), SubnetId::new(&[2]));
200    }
201
202    #[test]
203    fn test_multi_level() {
204        let policy = SubnetPolicy::new()
205            .add_rule(
206                SubnetRule::new("region:", 0)
207                    .map("us-west", 1)
208                    .map("eu-central", 2),
209            )
210            .add_rule(SubnetRule::new("fleet:", 1).map("alpha", 1).map("beta", 2));
211
212        let caps = caps_with_tags(&["region:us-west", "fleet:beta"]);
213        assert_eq!(policy.assign(&caps), SubnetId::new(&[1, 2]));
214    }
215
216    #[test]
217    fn test_unmatched_tag() {
218        let policy = SubnetPolicy::new().add_rule(SubnetRule::new("region:", 0).map("us-west", 1));
219
220        // Tag value not in the map
221        let caps = caps_with_tags(&["region:unknown"]);
222        assert_eq!(policy.assign(&caps), SubnetId::GLOBAL);
223
224        // No matching tag prefix
225        let caps = caps_with_tags(&["fleet:alpha"]);
226        assert_eq!(policy.assign(&caps), SubnetId::GLOBAL);
227    }
228
229    #[test]
230    fn test_partial_match() {
231        let policy = SubnetPolicy::new()
232            .add_rule(SubnetRule::new("region:", 0).map("us-west", 3))
233            .add_rule(SubnetRule::new("fleet:", 1).map("alpha", 7));
234
235        // Only region tag, no fleet
236        let caps = caps_with_tags(&["region:us-west"]);
237        assert_eq!(policy.assign(&caps), SubnetId::new(&[3]));
238    }
239
240    #[test]
241    fn test_four_levels() {
242        let policy = SubnetPolicy::new()
243            .add_rule(SubnetRule::new("region:", 0).map("us", 1))
244            .add_rule(SubnetRule::new("fleet:", 1).map("f1", 2))
245            .add_rule(SubnetRule::new("vehicle:", 2).map("v42", 3))
246            .add_rule(SubnetRule::new("subsystem:", 3).map("lidar", 4));
247
248        let caps = caps_with_tags(&["region:us", "fleet:f1", "vehicle:v42", "subsystem:lidar"]);
249        assert_eq!(policy.assign(&caps), SubnetId::new(&[1, 2, 3, 4]));
250    }
251
252    // ========================================================================
253    // Tie-breaking / ambiguity semantics (TEST_COVERAGE_PLAN §P3-17)
254    //
255    // Pins the three ambiguity cases the doc contract on
256    // `SubnetPolicy` calls out: same-prefix duplicate rules,
257    // rule-order dependency for the same level, and the no-partial-
258    // match contract on values. If any of these assertions flips,
259    // either the doc contract is wrong or a silent behavior change
260    // snuck in — the PR touching `assign()` needs to decide which.
261    // ========================================================================
262
263    /// Duplicate `tag_prefix` rules both writing the same level:
264    /// the later rule wins (last write). An earlier rule's mapping
265    /// is overwritten if a later rule matches the same tag input.
266    #[test]
267    fn duplicate_prefix_same_level_later_rule_wins() {
268        let policy = SubnetPolicy::new()
269            // First rule writes level 0 = 1
270            .add_rule(SubnetRule::new("region:", 0).map("us", 1))
271            // Second rule at the same level remaps "us" to 9
272            .add_rule(SubnetRule::new("region:", 0).map("us", 9));
273
274        let caps = caps_with_tags(&["region:us"]);
275        assert_eq!(
276            policy.assign(&caps),
277            SubnetId::new(&[9]),
278            "a later rule with the same prefix + level must overwrite \
279             the earlier rule's value — pinned as last-write-wins",
280        );
281    }
282
283    /// Duplicate `tag_prefix` rules writing *different* levels
284    /// coexist: both writes land on their respective level slots.
285    /// Exercises the "rules evaluated in declaration order, each
286    /// writes its own level independently" part of the contract.
287    #[test]
288    fn duplicate_prefix_different_levels_both_apply() {
289        let policy = SubnetPolicy::new()
290            .add_rule(SubnetRule::new("region:", 0).map("us", 1))
291            // Same prefix, different level — coexists with the first
292            .add_rule(SubnetRule::new("region:", 2).map("us", 5));
293
294        let caps = caps_with_tags(&["region:us"]);
295        assert_eq!(
296            policy.assign(&caps),
297            SubnetId::new(&[1, 0, 5, 0]),
298            "two rules sharing a prefix but targeting different \
299             levels must both fire; level 1 + 3 remain unset",
300        );
301    }
302
303    /// Rule-order dependency: when two rules both claim the same
304    /// level but match *different* tags, the later rule's match
305    /// still overwrites the earlier rule's match if both tags are
306    /// present on the node. Pins "later rule wins" even across
307    /// different tag prefixes targeting the same level.
308    #[test]
309    fn rule_order_dependency_later_rule_overwrites_earlier_level_write() {
310        let policy = SubnetPolicy::new()
311            // Earlier: region:* writes level 0
312            .add_rule(SubnetRule::new("region:", 0).map("us", 1))
313            // Later: zone:* ALSO writes level 0 — this rule comes
314            // after the first, so it wins when both tags match
315            .add_rule(SubnetRule::new("zone:", 0).map("west", 4));
316
317        let caps = caps_with_tags(&["region:us", "zone:west"]);
318        assert_eq!(
319            policy.assign(&caps),
320            SubnetId::new(&[4]),
321            "later rule targeting the same level must overwrite earlier one",
322        );
323
324        // And: if only the earlier rule's tag is present, level 0
325        // still ends up with the earlier rule's value (the later
326        // rule does not match any tag).
327        let caps = caps_with_tags(&["region:us"]);
328        assert_eq!(
329            policy.assign(&caps),
330            SubnetId::new(&[1]),
331            "later rule does not clobber when it has no matching tag",
332        );
333    }
334
335    /// No partial-match on the stripped value: `values` is an
336    /// exact-string lookup table, not a prefix matcher. A tag
337    /// carrying extra suffix after the prefix does not hit a rule
338    /// keyed on the bare inner token.
339    #[test]
340    fn partial_prefix_on_value_does_not_match() {
341        let policy = SubnetPolicy::new().add_rule(SubnetRule::new("region:", 0).map("us", 1));
342
343        // `region:us` → stripped "us" → hits values map.
344        let caps = caps_with_tags(&["region:us"]);
345        assert_eq!(policy.assign(&caps), SubnetId::new(&[1]));
346
347        // `region:us:extra` → stripped "us:extra" → NOT in map,
348        // so rule doesn't fire and level stays at zero.
349        let caps = caps_with_tags(&["region:us:extra"]);
350        assert_eq!(
351            policy.assign(&caps),
352            SubnetId::GLOBAL,
353            "values map is exact-match; suffixes after the matching \
354             inner token must not partial-match against the map key",
355        );
356
357        // Cousin case: tag where the stripped value is a *prefix*
358        // of a values-map entry doesn't match either.
359        let policy = SubnetPolicy::new().add_rule(SubnetRule::new("region:", 0).map("us-west", 1));
360        let caps = caps_with_tags(&["region:us"]);
361        assert_eq!(
362            policy.assign(&caps),
363            SubnetId::GLOBAL,
364            "stripped value \"us\" is a prefix of \"us-west\" but \
365             must not partial-match the values map key",
366        );
367    }
368
369    /// First matching tag wins *within* a single rule — a second
370    /// tag for the same rule is ignored (the `break` in `assign`).
371    /// Phase A.5.N.2: tags are now `HashSet<Tag>` (unordered);
372    /// `assign()` sorts tag strings lexicographically before the
373    /// first-match scan, so the result is deterministic regardless
374    /// of insertion order.
375    #[test]
376    fn first_tag_wins_within_a_single_rule() {
377        let policy =
378            SubnetPolicy::new().add_rule(SubnetRule::new("region:", 0).map("us", 1).map("eu", 2));
379
380        // Both insertions converge on the same answer — the
381        // lexicographically-first matching tag wins. `region:eu`
382        // sorts before `region:us`, so 2 (eu's level value) wins
383        // regardless of which tag was inserted first.
384        let caps = caps_with_tags(&["region:us", "region:eu"]);
385        assert_eq!(
386            policy.assign(&caps),
387            SubnetId::new(&[2]),
388            "lexicographically-first matching tag wins (`region:eu` < `region:us`)",
389        );
390
391        let caps = caps_with_tags(&["region:eu", "region:us"]);
392        assert_eq!(
393            policy.assign(&caps),
394            SubnetId::new(&[2]),
395            "insertion order is irrelevant — the same tag still wins",
396        );
397    }
398
399    /// Out-of-range `level` must surface as `Err(...)`,
400    /// not panic. Subnet policies typically come from config /
401    /// FFI / JSON; a malformed entry must not crash the daemon
402    /// loader.
403    #[test]
404    fn try_add_rule_rejects_level_out_of_range() {
405        let policy = SubnetPolicy::new();
406        let err = policy
407            .try_add_rule(SubnetRule::new("region:", 4).map("us", 1))
408            .unwrap_err();
409        assert!(
410            matches!(err, SubnetError::LevelOutOfRange { got: 4 }),
411            "expected LevelOutOfRange{{got: 4}}, got {:?}",
412            err
413        );
414    }
415
416    #[test]
417    fn try_add_rule_accepts_max_level() {
418        let policy = SubnetPolicy::new();
419        // Level 3 is the highest valid level (0..=3).
420        policy
421            .try_add_rule(SubnetRule::new("level3:", 3).map("x", 1))
422            .expect("level=3 must be accepted (boundary)");
423    }
424
425    /// Zero `level_value` must surface as `Err(...)`,
426    /// not panic.
427    #[test]
428    fn try_map_rejects_reserved_zero() {
429        let rule = SubnetRule::new("region:", 0);
430        let err = rule.try_map("us", 0).unwrap_err();
431        assert!(
432            matches!(err, SubnetError::LevelValueReserved),
433            "expected LevelValueReserved, got {:?}",
434            err
435        );
436    }
437
438    #[test]
439    fn try_map_accepts_one() {
440        // 1 is the lowest non-reserved level value.
441        SubnetRule::new("region:", 0)
442            .try_map("us", 1)
443            .expect("level_value=1 must be accepted (boundary)");
444    }
445}