1use serde_json::{Value, json};
2use sofie_core::{Error, Fnv1a64, Result, Uuid};
3
4#[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 let listed =
59 |list: &[String], value: &str| list.is_empty() || list.iter().any(|item| item == value);
60 if !self.on || !listed(&self.environments, environment) {
61 return false;
62 }
63 if !self.areas.is_empty() && !subject.area.is_some_and(|area| listed(&self.areas, area)) {
64 return false;
65 }
66 if self.percentage >= 100 {
67 return true;
68 }
69 subject.principal.is_some_and(|principal| {
70 self.principals.contains(&principal)
71 || bucket(&[name], principal, 100) < u64::from(self.percentage)
72 })
73 }
74
75 pub(crate) fn variant(&self, name: &str, subject: Subject) -> Option<&str> {
76 let weighted = self.variants.iter().filter(|(_, weight)| *weight > 0);
77 let total: u64 = weighted.clone().map(|(_, weight)| u64::from(*weight)).sum();
78 let Some(principal) = subject.principal else {
79 return weighted.map(|(variant, _)| variant.as_str()).next();
80 };
81 let mut point = bucket(&[name, "/variant"], principal, total.max(1));
82 for (variant, weight) in weighted {
83 match point.checked_sub(u64::from(*weight)) {
84 Some(rest) => point = rest,
85 None => return Some(variant),
86 }
87 }
88 None
89 }
90
91 pub(crate) fn to_json(&self) -> String {
92 let principals: Vec<String> = self.principals.iter().map(ToString::to_string).collect();
93 json!({
94 "on": self.on,
95 "environments": self.environments,
96 "areas": self.areas,
97 "principals": principals,
98 "percentage": self.percentage,
99 "variants": self.variants,
100 })
101 .to_string()
102 }
103
104 pub(crate) fn from_json(name: &str, text: &str) -> Result<Self> {
105 let malformed = |what: &str| {
106 Error::internal_message(format!("the stored rule of flag `{name}` has {what}"))
107 };
108 let value: Value = serde_json::from_str(text).map_err(|error| {
109 Error::internal(format!("reading the stored rule of flag `{name}`"), error)
110 })?;
111 let texts = |key: &str| -> Result<Vec<String>> {
112 value[key]
113 .as_array()
114 .ok_or_else(|| malformed(&format!("no list `{key}`")))?
115 .iter()
116 .map(|item| {
117 item.as_str()
118 .map(str::to_owned)
119 .ok_or_else(|| malformed(&format!("a non-text entry in `{key}`")))
120 })
121 .collect()
122 };
123 let principals = texts("principals")?
124 .iter()
125 .map(|text| {
126 text.parse()
127 .map_err(|_| malformed("a principal that is not an id"))
128 })
129 .collect::<Result<_>>()?;
130 let rule = Self {
131 on: value["on"]
132 .as_bool()
133 .ok_or_else(|| malformed("no switch `on`"))?,
134 environments: texts("environments")?,
135 areas: texts("areas")?,
136 principals,
137 percentage: value["percentage"]
138 .as_u64()
139 .and_then(|percentage| u8::try_from(percentage).ok())
140 .ok_or_else(|| malformed("no `percentage`"))?,
141 variants: variants_of(&value["variants"])
142 .ok_or_else(|| malformed("malformed `variants`"))?,
143 };
144 rule.validate(name)?;
145 Ok(rule)
146 }
147
148 pub fn validate(&self, name: &str) -> Result<()> {
149 if self.percentage > 100 {
150 return Err(Error::invalid(
151 "percentage",
152 format!("of flag `{name}` must be between 0 and 100"),
153 ));
154 }
155 refuse_malformed_entries("environments", &self.environments)?;
156 refuse_malformed_entries("areas", &self.areas)?;
157 let variants: Vec<String> = self
158 .variants
159 .iter()
160 .map(|(variant, _)| variant.clone())
161 .collect();
162 refuse_malformed_entries("variants", &variants)
163 }
164}
165
166fn refuse_malformed_entries(field: &str, entries: &[String]) -> Result<()> {
168 let named = |entry: &String| {
169 !entry.is_empty()
170 && entry
171 .chars()
172 .all(|character| character.is_ascii_alphanumeric() || "_-".contains(character))
173 };
174 if let Some(malformed) = entries.iter().find(|entry| !named(entry)) {
175 return Err(Error::invalid(
176 field,
177 format!("`{malformed}` is not a name of letters, digits, `_` and `-`"),
178 ));
179 }
180 let repeated = entries
181 .iter()
182 .enumerate()
183 .find(|(index, entry)| entries[..*index].contains(entry));
184 if let Some((_, entry)) = repeated {
185 return Err(Error::invalid(field, format!("lists `{entry}` twice")));
186 }
187 Ok(())
188}
189
190fn variants_of(value: &Value) -> Option<Vec<(String, u32)>> {
191 value
192 .as_array()?
193 .iter()
194 .map(|pair| {
195 let name = pair.get(0)?.as_str()?.to_owned();
196 let weight = u32::try_from(pair.get(1)?.as_u64()?).ok()?;
197 Some((name, weight))
198 })
199 .collect()
200}
201
202fn bucket(name: &[&str], principal: Uuid, range: u64) -> u64 {
204 let hash = name
205 .iter()
206 .fold(Fnv1a64::new(), |hash, part| hash.write(part.as_bytes()))
207 .write(&[0])
208 .write(principal.as_bytes())
209 .finish();
210 mixed(hash) % range
211}
212
213fn mixed(mut hash: u64) -> u64 {
216 hash ^= hash >> 30;
217 hash = hash.wrapping_mul(0xbf58_476d_1ce4_e5b9);
218 hash ^= hash >> 27;
219 hash = hash.wrapping_mul(0x94d0_49bb_1331_11eb);
220 hash ^ (hash >> 31)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn principal(n: u128) -> Uuid {
228 Uuid::from_u128(n)
229 }
230
231 fn someone(n: u128) -> Subject {
232 Subject {
233 area: Some("member"),
234 principal: Some(principal(n)),
235 }
236 }
237
238 #[test]
239 fn off_is_off_and_everyone_is_on_for_anyone() {
240 assert!(!Rule::off().is_on("a.b", "production", someone(1)));
241 assert!(Rule::everyone().is_on("a.b", "production", Subject::default()));
242 }
243
244 #[test]
245 fn environments_and_areas_narrow_it() {
246 let rule = Rule {
247 environments: vec!["staging".to_owned()],
248 areas: vec!["console".to_owned()],
249 ..Rule::everyone()
250 };
251 let console = Subject {
252 area: Some("console"),
253 principal: None,
254 };
255 assert!(rule.is_on("a.b", "staging", console));
256 assert!(!rule.is_on("a.b", "production", console));
257 assert!(!rule.is_on("a.b", "staging", someone(1)));
258 assert!(!rule.is_on("a.b", "staging", Subject::default()));
259 }
260
261 fn split() -> Rule {
262 Rule {
263 variants: vec![("a".to_owned(), 1), ("b".to_owned(), 1)],
264 ..Rule::everyone()
265 }
266 }
267
268 #[test]
269 fn two_flags_split_principals_independently() {
270 let (first, second) = (split(), split());
271 let principals =
272 (1..=4_000_u128).map(|n| n.wrapping_mul(0x9e37_79b9_7f4a_7c15_f39c_c060_5ced_c835));
273 let same = principals
274 .filter(|n| {
275 let subject = someone(*n);
276 first.variant("a.first", subject) == second.variant("a.second", subject)
277 })
278 .count();
279 assert!((1_700..2_300).contains(&same), "{same}");
280 }
281
282 #[test]
283 fn a_rollout_does_not_choose_its_principals_variant() {
284 let rule = Rule {
285 percentage: 10,
286 ..split()
287 };
288 let rolled_in: Vec<Subject> = (1..=20_000_u128)
289 .map(someone)
290 .filter(|subject| rule.is_on("a.b", "x", *subject))
291 .collect();
292 let first_arm = rolled_in
293 .iter()
294 .filter(|subject| rule.variant("a.b", **subject) == Some("a"))
295 .count();
296 let share = first_arm * 100 / rolled_in.len();
297 assert!(
298 (40..=60).contains(&share),
299 "{share}% of {}",
300 rolled_in.len()
301 );
302 }
303
304 #[test]
305 fn a_percentage_is_stable_per_principal_and_near_its_share() {
306 let rule = Rule {
307 percentage: 30,
308 ..Rule::everyone()
309 };
310 let on = (0..10_000)
311 .filter(|n| rule.is_on("a.b", "x", someone(*n)))
312 .count();
313 assert!((2_700..3_300).contains(&on), "{on}");
314 let first: Vec<bool> = (0..50)
315 .map(|n| rule.is_on("a.b", "x", someone(n)))
316 .collect();
317 let again: Vec<bool> = (0..50)
318 .map(|n| rule.is_on("a.b", "x", someone(n)))
319 .collect();
320 assert_eq!(first, again);
321 assert!(!rule.is_on("a.b", "x", Subject::default()));
322 }
323
324 #[test]
325 fn listed_principals_are_in_whatever_the_percentage() {
326 let rule = Rule {
327 percentage: 0,
328 principals: vec![principal(7)],
329 ..Rule::everyone()
330 };
331 assert!(rule.is_on("a.b", "x", someone(7)));
332 assert!(!rule.is_on("a.b", "x", someone(8)));
333 }
334
335 #[test]
336 fn variants_split_by_weight_and_default_to_the_first() {
337 let rule = Rule {
338 variants: vec![("classic".to_owned(), 1), ("compact".to_owned(), 3)],
339 ..Rule::everyone()
340 };
341 let compact = (0..4_000)
342 .filter(|n| rule.variant("a.b", someone(*n)) == Some("compact"))
343 .count();
344 assert!((2_800..3_200).contains(&compact), "{compact}");
345 assert_eq!(rule.variant("a.b", Subject::default()), Some("classic"));
346 }
347
348 #[test]
349 fn a_rule_survives_its_stored_form() {
350 let rule = Rule {
351 environments: vec!["production".to_owned()],
352 areas: vec!["member".to_owned()],
353 principals: vec![principal(3)],
354 percentage: 25,
355 variants: vec![("a".to_owned(), 1)],
356 ..Rule::everyone()
357 };
358 assert_eq!(Rule::from_json("a.b", &rule.to_json()).unwrap(), rule);
359 assert!(Rule::from_json("a.b", "{\"on\":true}").is_err());
360 }
361
362 #[test]
363 fn a_target_that_could_never_match_is_refused() {
364 for refused in [
365 Rule {
366 areas: vec![String::new()],
367 ..Rule::everyone()
368 },
369 Rule {
370 environments: vec!["pro duction".to_owned()],
371 ..Rule::everyone()
372 },
373 Rule {
374 variants: vec![("a".to_owned(), 1), ("a".to_owned(), 2)],
375 ..Rule::everyone()
376 },
377 ] {
378 assert!(refused.validate("a.b").is_err(), "{refused:?}");
379 }
380 }
381
382 #[test]
383 fn settled_means_the_same_for_everyone() {
384 assert!(Rule::off().is_settled());
385 assert!(Rule::everyone().is_settled());
386 let partial = Rule {
387 percentage: 50,
388 ..Rule::everyone()
389 };
390 assert!(!partial.is_settled());
391 }
392}