Skip to main content

sofie_flags/
rule.rs

1use serde_json::{Value, json};
2use sofie_core::{Error, Fnv1a64, Result, Uuid};
3
4/// A request without a principal, and a job, are in only at 100 percent.
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct Rule {
7    pub on: bool,
8    pub environments: Vec<String>,
9    pub areas: Vec<String>,
10    pub principals: Vec<Uuid>,
11    pub percentage: u8,
12    pub variants: Vec<(String, u32)>,
13}
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub struct Subject {
17    pub area: Option<&'static str>,
18    pub principal: Option<Uuid>,
19}
20
21impl Rule {
22    #[must_use]
23    pub fn off() -> Self {
24        Self {
25            on: false,
26            environments: Vec::new(),
27            areas: Vec::new(),
28            principals: Vec::new(),
29            percentage: 100,
30            variants: Vec::new(),
31        }
32    }
33
34    #[must_use]
35    pub fn everyone() -> Self {
36        Self {
37            on: true,
38            ..Self::off()
39        }
40    }
41
42    #[must_use]
43    pub fn is_settled(&self) -> bool {
44        let one_variant = self
45            .variants
46            .iter()
47            .filter(|(_, weight)| *weight > 0)
48            .count()
49            <= 1;
50        !self.on
51            || (self.environments.is_empty()
52                && self.areas.is_empty()
53                && self.percentage == 100
54                && one_variant)
55    }
56
57    pub(crate) fn is_on(&self, name: &str, environment: &str, subject: Subject) -> bool {
58        if !self.applies(environment, subject.area) {
59            return false;
60        }
61        if self.percentage >= 100 {
62            return true;
63        }
64        subject.principal.is_some_and(|principal| {
65            self.principals.contains(&principal)
66                || bucket(&[name], principal, 100) < u64::from(self.percentage)
67        })
68    }
69
70    /// On for at least one principal of the area, whoever asks.
71    pub(crate) fn is_on_for_someone(&self, environment: &str, area: Option<&str>) -> bool {
72        self.applies(environment, area) && (self.percentage > 0 || !self.principals.is_empty())
73    }
74
75    fn applies(&self, environment: &str, area: Option<&str>) -> bool {
76        let listed =
77            |list: &[String], value: &str| list.is_empty() || list.iter().any(|item| item == value);
78        self.on
79            && listed(&self.environments, environment)
80            && (self.areas.is_empty() || area.is_some_and(|area| listed(&self.areas, area)))
81    }
82
83    pub(crate) fn variant(&self, name: &str, subject: Subject) -> Option<&str> {
84        let weighted = self.variants.iter().filter(|(_, weight)| *weight > 0);
85        let total: u64 = weighted.clone().map(|(_, weight)| u64::from(*weight)).sum();
86        let Some(principal) = subject.principal else {
87            return weighted.map(|(variant, _)| variant.as_str()).next();
88        };
89        let mut point = bucket(&[name, "/variant"], principal, total.max(1));
90        for (variant, weight) in weighted {
91            match point.checked_sub(u64::from(*weight)) {
92                Some(rest) => point = rest,
93                None => return Some(variant),
94            }
95        }
96        None
97    }
98
99    pub(crate) fn to_json(&self) -> String {
100        let principals: Vec<String> = self.principals.iter().map(ToString::to_string).collect();
101        json!({
102            "on": self.on,
103            "environments": self.environments,
104            "areas": self.areas,
105            "principals": principals,
106            "percentage": self.percentage,
107            "variants": self.variants,
108        })
109        .to_string()
110    }
111
112    pub(crate) fn from_json(name: &str, text: &str) -> Result<Self> {
113        let malformed = |what: &str| {
114            Error::internal_message(format!("the stored rule of flag `{name}` has {what}"))
115        };
116        let value: Value = serde_json::from_str(text).map_err(|error| {
117            Error::internal(format!("reading the stored rule of flag `{name}`"), error)
118        })?;
119        let texts = |key: &str| -> Result<Vec<String>> {
120            value[key]
121                .as_array()
122                .ok_or_else(|| malformed(&format!("no list `{key}`")))?
123                .iter()
124                .map(|item| {
125                    item.as_str()
126                        .map(str::to_owned)
127                        .ok_or_else(|| malformed(&format!("a non-text entry in `{key}`")))
128                })
129                .collect()
130        };
131        let principals = texts("principals")?
132            .iter()
133            .map(|text| {
134                text.parse()
135                    .map_err(|_| malformed("a principal that is not an id"))
136            })
137            .collect::<Result<_>>()?;
138        let rule = Self {
139            on: value["on"]
140                .as_bool()
141                .ok_or_else(|| malformed("no switch `on`"))?,
142            environments: texts("environments")?,
143            areas: texts("areas")?,
144            principals,
145            percentage: value["percentage"]
146                .as_u64()
147                .and_then(|percentage| u8::try_from(percentage).ok())
148                .ok_or_else(|| malformed("no `percentage`"))?,
149            variants: variants_of(&value["variants"])
150                .ok_or_else(|| malformed("malformed `variants`"))?,
151        };
152        rule.validate(name)?;
153        Ok(rule)
154    }
155
156    pub fn validate(&self, name: &str) -> Result<()> {
157        if self.percentage > 100 {
158            return Err(Error::invalid(
159                "percentage",
160                format!("of flag `{name}` must be between 0 and 100"),
161            ));
162        }
163        refuse_malformed_entries("environments", &self.environments)?;
164        refuse_malformed_entries("areas", &self.areas)?;
165        let variants: Vec<String> = self
166            .variants
167            .iter()
168            .map(|(variant, _)| variant.clone())
169            .collect();
170        refuse_malformed_entries("variants", &variants)
171    }
172}
173
174/// An entry is matched against a name the code declares, so one that could never match is refused.
175fn refuse_malformed_entries(field: &str, entries: &[String]) -> Result<()> {
176    let named = |entry: &String| {
177        !entry.is_empty()
178            && entry
179                .chars()
180                .all(|character| character.is_ascii_alphanumeric() || "_-".contains(character))
181    };
182    if let Some(malformed) = entries.iter().find(|entry| !named(entry)) {
183        return Err(Error::invalid(
184            field,
185            format!("`{malformed}` is not a name of letters, digits, `_` and `-`"),
186        ));
187    }
188    let repeated = entries
189        .iter()
190        .enumerate()
191        .find(|(index, entry)| entries[..*index].contains(entry));
192    if let Some((_, entry)) = repeated {
193        return Err(Error::invalid(field, format!("lists `{entry}` twice")));
194    }
195    Ok(())
196}
197
198fn variants_of(value: &Value) -> Option<Vec<(String, u32)>> {
199    value
200        .as_array()?
201        .iter()
202        .map(|pair| {
203            let name = pair.get(0)?.as_str()?.to_owned();
204            let weight = u32::try_from(pair.get(1)?.as_u64()?).ok()?;
205            Some((name, weight))
206        })
207        .collect()
208}
209
210/// The parts hash as their concatenation, so splitting a name never moves anyone's bucket.
211fn bucket(name: &[&str], principal: Uuid, range: u64) -> u64 {
212    let hash = name
213        .iter()
214        .fold(Fnv1a64::new(), |hash, part| hash.write(part.as_bytes()))
215        .write(&[0])
216        .write(principal.as_bytes())
217        .finish();
218    mixed(hash) % range
219}
220
221/// FNV-1a leaves its low bits a function of the input bytes' parity, so two flags' buckets
222/// would decide each other; splitmix64's finalizer spreads every input bit over the output.
223fn mixed(mut hash: u64) -> u64 {
224    hash ^= hash >> 30;
225    hash = hash.wrapping_mul(0xbf58_476d_1ce4_e5b9);
226    hash ^= hash >> 27;
227    hash = hash.wrapping_mul(0x94d0_49bb_1331_11eb);
228    hash ^ (hash >> 31)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn principal(n: u128) -> Uuid {
236        Uuid::from_u128(n)
237    }
238
239    fn someone(n: u128) -> Subject {
240        Subject {
241            area: Some("member"),
242            principal: Some(principal(n)),
243        }
244    }
245
246    #[test]
247    fn off_is_off_and_everyone_is_on_for_anyone() {
248        assert!(!Rule::off().is_on("a.b", "production", someone(1)));
249        assert!(Rule::everyone().is_on("a.b", "production", Subject::default()));
250    }
251
252    #[test]
253    fn environments_and_areas_narrow_it() {
254        let rule = Rule {
255            environments: vec!["staging".to_owned()],
256            areas: vec!["console".to_owned()],
257            ..Rule::everyone()
258        };
259        let console = Subject {
260            area: Some("console"),
261            principal: None,
262        };
263        assert!(rule.is_on("a.b", "staging", console));
264        assert!(!rule.is_on("a.b", "production", console));
265        assert!(!rule.is_on("a.b", "staging", someone(1)));
266        assert!(!rule.is_on("a.b", "staging", Subject::default()));
267    }
268
269    #[test]
270    fn a_partial_rollout_is_on_for_someone_though_off_for_a_caller_without_a_principal() {
271        let listed = Rule {
272            principals: vec![principal(1)],
273            percentage: 0,
274            ..Rule::everyone()
275        };
276        let partial = Rule {
277            percentage: 10,
278            ..Rule::everyone()
279        };
280        let nobody = Rule {
281            percentage: 0,
282            ..Rule::everyone()
283        };
284        for rule in [&listed, &partial] {
285            assert!(!rule.is_on("a.b", "production", Subject::default()));
286            assert!(rule.is_on_for_someone("production", Some("member")));
287        }
288        assert!(!nobody.is_on_for_someone("production", Some("member")));
289        assert!(!Rule::off().is_on_for_someone("production", Some("member")));
290    }
291
292    fn split() -> Rule {
293        Rule {
294            variants: vec![("a".to_owned(), 1), ("b".to_owned(), 1)],
295            ..Rule::everyone()
296        }
297    }
298
299    #[test]
300    fn two_flags_split_principals_independently() {
301        let (first, second) = (split(), split());
302        let principals =
303            (1..=4_000_u128).map(|n| n.wrapping_mul(0x9e37_79b9_7f4a_7c15_f39c_c060_5ced_c835));
304        let same = principals
305            .filter(|n| {
306                let subject = someone(*n);
307                first.variant("a.first", subject) == second.variant("a.second", subject)
308            })
309            .count();
310        assert!((1_700..2_300).contains(&same), "{same}");
311    }
312
313    #[test]
314    fn a_rollout_does_not_choose_its_principals_variant() {
315        let rule = Rule {
316            percentage: 10,
317            ..split()
318        };
319        let rolled_in: Vec<Subject> = (1..=20_000_u128)
320            .map(someone)
321            .filter(|subject| rule.is_on("a.b", "x", *subject))
322            .collect();
323        let first_arm = rolled_in
324            .iter()
325            .filter(|subject| rule.variant("a.b", **subject) == Some("a"))
326            .count();
327        let share = first_arm * 100 / rolled_in.len();
328        assert!(
329            (40..=60).contains(&share),
330            "{share}% of {}",
331            rolled_in.len()
332        );
333    }
334
335    #[test]
336    fn a_percentage_is_stable_per_principal_and_near_its_share() {
337        let rule = Rule {
338            percentage: 30,
339            ..Rule::everyone()
340        };
341        let on = (0..10_000)
342            .filter(|n| rule.is_on("a.b", "x", someone(*n)))
343            .count();
344        assert!((2_700..3_300).contains(&on), "{on}");
345        let first: Vec<bool> = (0..50)
346            .map(|n| rule.is_on("a.b", "x", someone(n)))
347            .collect();
348        let again: Vec<bool> = (0..50)
349            .map(|n| rule.is_on("a.b", "x", someone(n)))
350            .collect();
351        assert_eq!(first, again);
352        assert!(!rule.is_on("a.b", "x", Subject::default()));
353    }
354
355    #[test]
356    fn listed_principals_are_in_whatever_the_percentage() {
357        let rule = Rule {
358            percentage: 0,
359            principals: vec![principal(7)],
360            ..Rule::everyone()
361        };
362        assert!(rule.is_on("a.b", "x", someone(7)));
363        assert!(!rule.is_on("a.b", "x", someone(8)));
364    }
365
366    #[test]
367    fn variants_split_by_weight_and_default_to_the_first() {
368        let rule = Rule {
369            variants: vec![("classic".to_owned(), 1), ("compact".to_owned(), 3)],
370            ..Rule::everyone()
371        };
372        let compact = (0..4_000)
373            .filter(|n| rule.variant("a.b", someone(*n)) == Some("compact"))
374            .count();
375        assert!((2_800..3_200).contains(&compact), "{compact}");
376        assert_eq!(rule.variant("a.b", Subject::default()), Some("classic"));
377    }
378
379    #[test]
380    fn a_rule_survives_its_stored_form() {
381        let rule = Rule {
382            environments: vec!["production".to_owned()],
383            areas: vec!["member".to_owned()],
384            principals: vec![principal(3)],
385            percentage: 25,
386            variants: vec![("a".to_owned(), 1)],
387            ..Rule::everyone()
388        };
389        assert_eq!(Rule::from_json("a.b", &rule.to_json()).unwrap(), rule);
390        assert!(Rule::from_json("a.b", "{\"on\":true}").is_err());
391    }
392
393    #[test]
394    fn a_target_that_could_never_match_is_refused() {
395        for refused in [
396            Rule {
397                areas: vec![String::new()],
398                ..Rule::everyone()
399            },
400            Rule {
401                environments: vec!["pro duction".to_owned()],
402                ..Rule::everyone()
403            },
404            Rule {
405                variants: vec![("a".to_owned(), 1), ("a".to_owned(), 2)],
406                ..Rule::everyone()
407            },
408        ] {
409            assert!(refused.validate("a.b").is_err(), "{refused:?}");
410        }
411    }
412
413    #[test]
414    fn settled_means_the_same_for_everyone() {
415        assert!(Rule::off().is_settled());
416        assert!(Rule::everyone().is_settled());
417        let partial = Rule {
418            percentage: 50,
419            ..Rule::everyone()
420        };
421        assert!(!partial.is_settled());
422    }
423}