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