1use std::collections::BTreeMap;
11use std::sync::OnceLock;
12
13use globset::{Glob, GlobMatcher};
14use objectstore_service::id::ObjectContext;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Default, Deserialize, Serialize)]
19pub struct Killswitches(pub Vec<Killswitch>);
20
21impl Killswitches {
22 pub fn matches(&self, context: &ObjectContext, service: Option<&str>) -> bool {
24 self.0.iter().any(|s| s.matches(context, service))
25 }
26}
27
28#[derive(Debug, Deserialize, Serialize)]
33pub struct Killswitch {
34 #[serde(default)]
38 pub usecase: Option<String>,
39
40 #[serde(default)]
45 pub scopes: BTreeMap<String, String>,
46
47 #[serde(default)]
52 pub service: Option<String>,
53
54 #[serde(skip)]
59 #[serde(default)]
60 pub service_matcher: OnceLock<Option<GlobMatcher>>,
61}
62
63impl PartialEq for Killswitch {
64 fn eq(&self, other: &Self) -> bool {
65 self.usecase == other.usecase
66 && self.scopes == other.scopes
67 && self.service == other.service
68 }
70}
71
72impl Killswitch {
73 pub fn matches(&self, context: &ObjectContext, service: Option<&str>) -> bool {
75 if let Some(ref switch_usecase) = self.usecase
76 && switch_usecase != &context.usecase
77 {
78 return false;
79 }
80
81 for (scope_name, scope_value) in &self.scopes {
82 match context.scopes.get_value(scope_name) {
83 Some(value) if value == scope_value => (),
84 _ => return false,
85 }
86 }
87
88 if let Some(ref pattern) = self.service {
90 let Some(service_value) = service else {
92 return false;
93 };
94
95 let matcher = self
96 .service_matcher
97 .get_or_init(|| Glob::new(pattern).ok().map(|g| g.compile_matcher()));
98
99 match matcher {
100 Some(m) if m.is_match(service_value) => (),
101 _ => return false,
102 }
103 }
104
105 true
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use objectstore_types::scope::{Scope, Scopes};
112
113 use super::*;
114
115 #[test]
116 fn test_matches_empty() {
117 let switch = Killswitch {
118 usecase: None,
119 scopes: BTreeMap::new(),
120 service: None,
121 service_matcher: OnceLock::new(),
122 };
123
124 let context = ObjectContext {
125 usecase: "any".to_string(),
126 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
127 };
128
129 assert!(switch.matches(&context, None));
130 }
131
132 #[test]
133 fn test_matches_usecase() {
134 let switch = Killswitch {
135 usecase: Some("test".to_string()),
136 scopes: BTreeMap::new(),
137 service: None,
138 service_matcher: OnceLock::new(),
139 };
140
141 let context = ObjectContext {
142 usecase: "test".to_string(),
143 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
144 };
145 assert!(switch.matches(&context, Some("anyservice")));
146
147 let context = ObjectContext {
149 usecase: "other".to_string(),
150 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
151 };
152 assert!(!switch.matches(&context, Some("anyservice")));
153 }
154
155 #[test]
156 fn test_matches_scopes() {
157 let switch = Killswitch {
158 usecase: None,
159 scopes: BTreeMap::from([
160 ("org".to_string(), "123".to_string()),
161 ("project".to_string(), "456".to_string()),
162 ]),
163 service: None,
164 service_matcher: OnceLock::new(),
165 };
166
167 let context = ObjectContext {
169 usecase: "any".to_string(),
170 scopes: Scopes::from_iter([
171 Scope::create("org", "123").unwrap(),
172 Scope::create("project", "456").unwrap(),
173 Scope::create("extra", "789").unwrap(),
174 ]),
175 };
176 assert!(switch.matches(&context, Some("anyservice")));
177
178 let context = ObjectContext {
180 usecase: "any".to_string(),
181 scopes: Scopes::from_iter([
182 Scope::create("org", "123").unwrap(),
183 Scope::create("project", "999").unwrap(),
184 ]),
185 };
186 assert!(!switch.matches(&context, Some("anyservice")));
187
188 let context = ObjectContext {
190 usecase: "any".to_string(),
191 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
192 };
193 assert!(!switch.matches(&context, Some("anyservice")));
194 }
195
196 #[test]
197 fn test_matches_full() {
198 let switch = Killswitch {
199 usecase: Some("test".to_string()),
200 scopes: BTreeMap::from([("org".to_string(), "123".to_string())]),
201 service: Some("myservice-*".to_string()),
202 service_matcher: OnceLock::new(),
203 };
204
205 let context = ObjectContext {
207 usecase: "test".to_string(),
208 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
209 };
210 assert!(switch.matches(&context, Some("myservice-prod")));
211
212 let context = ObjectContext {
214 usecase: "other".to_string(),
215 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
216 };
217 assert!(!switch.matches(&context, Some("myservice-prod")));
218
219 let context = ObjectContext {
221 usecase: "test".to_string(),
222 scopes: Scopes::from_iter([Scope::create("org", "999").unwrap()]),
223 };
224 assert!(!switch.matches(&context, Some("myservice-prod")));
225
226 let context = ObjectContext {
228 usecase: "test".to_string(),
229 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
230 };
231 assert!(!switch.matches(&context, Some("otherservice")));
232
233 let context = ObjectContext {
235 usecase: "test".to_string(),
236 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
237 };
238 assert!(!switch.matches(&context, None));
239 }
240
241 #[test]
242 fn test_matches_service_exact() {
243 let switch = Killswitch {
244 usecase: None,
245 scopes: BTreeMap::new(),
246 service: Some("myservice".to_string()),
247 service_matcher: OnceLock::new(),
248 };
249
250 let context = ObjectContext {
251 usecase: "any".to_string(),
252 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
253 };
254
255 assert!(switch.matches(&context, Some("myservice")));
256 assert!(!switch.matches(&context, Some("otherservice")));
257 assert!(!switch.matches(&context, None));
258 }
259
260 #[test]
261 fn test_matches_service_glob() {
262 let switch = Killswitch {
263 usecase: None,
264 scopes: BTreeMap::new(),
265 service: Some("myservice-*".to_string()),
266 service_matcher: OnceLock::new(),
267 };
268
269 let context = ObjectContext {
270 usecase: "any".to_string(),
271 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
272 };
273
274 assert!(switch.matches(&context, Some("myservice-prod")));
276 assert!(switch.matches(&context, Some("myservice-dev")));
277 assert!(switch.matches(&context, Some("myservice-staging")));
278
279 assert!(!switch.matches(&context, Some("otherservice")));
281 assert!(!switch.matches(&context, Some("otherservice-prod")));
282
283 assert!(!switch.matches(&context, Some("myservice")));
285 }
286
287 #[test]
288 fn test_matches_service_invalid_glob() {
289 let switch = Killswitch {
290 usecase: None,
291 scopes: BTreeMap::new(),
292 service: Some("[invalid".to_string()), service_matcher: OnceLock::new(),
294 };
295
296 let context = ObjectContext {
297 usecase: "any".to_string(),
298 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
299 };
300
301 assert!(!switch.matches(&context, Some("anyservice")));
303 assert!(!switch.matches(&context, Some("[invalid")));
304 }
305}