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 {
26 let Some(killswitch) = self.find(context, service) else {
27 return false;
28 };
29
30 objectstore_metrics::count!("server.request.killswitched");
31 objectstore_log::warn!(?killswitch, "Request rejected: killswitch active");
32 true
33 }
34
35 pub fn find<'a>(
37 &'a self,
38 context: &ObjectContext,
39 service: Option<&str>,
40 ) -> Option<&'a Killswitch> {
41 self.0.iter().find(|s| s.matches(context, service))
42 }
43}
44
45#[derive(Debug, Deserialize, Serialize)]
50pub struct Killswitch {
51 #[serde(default)]
55 pub usecase: Option<String>,
56
57 #[serde(default)]
62 pub scopes: BTreeMap<String, String>,
63
64 #[serde(default)]
69 pub service: Option<String>,
70
71 #[serde(skip)]
76 #[serde(default)]
77 pub service_matcher: OnceLock<Option<GlobMatcher>>,
78}
79
80impl PartialEq for Killswitch {
81 fn eq(&self, other: &Self) -> bool {
82 self.usecase == other.usecase
83 && self.scopes == other.scopes
84 && self.service == other.service
85 }
87}
88
89impl Killswitch {
90 pub fn matches(&self, context: &ObjectContext, service: Option<&str>) -> bool {
92 if let Some(ref switch_usecase) = self.usecase
93 && switch_usecase != &context.usecase
94 {
95 return false;
96 }
97
98 for (scope_name, scope_value) in &self.scopes {
99 match context.scopes.get_value(scope_name) {
100 Some(value) if value == scope_value => (),
101 _ => return false,
102 }
103 }
104
105 if let Some(ref pattern) = self.service {
107 let Some(service_value) = service else {
109 return false;
110 };
111
112 let matcher = self
113 .service_matcher
114 .get_or_init(|| Glob::new(pattern).ok().map(|g| g.compile_matcher()));
115
116 match matcher {
117 Some(m) if m.is_match(service_value) => (),
118 _ => return false,
119 }
120 }
121
122 true
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use objectstore_types::scope::{Scope, Scopes};
129
130 use super::*;
131
132 #[test]
133 fn test_matches_empty() {
134 let switch = Killswitch {
135 usecase: None,
136 scopes: BTreeMap::new(),
137 service: None,
138 service_matcher: OnceLock::new(),
139 };
140
141 let context = ObjectContext {
142 usecase: "any".to_string(),
143 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
144 };
145
146 assert!(switch.matches(&context, None));
147 }
148
149 #[test]
150 fn test_matches_usecase() {
151 let switch = Killswitch {
152 usecase: Some("test".to_string()),
153 scopes: BTreeMap::new(),
154 service: None,
155 service_matcher: OnceLock::new(),
156 };
157
158 let context = ObjectContext {
159 usecase: "test".to_string(),
160 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
161 };
162 assert!(switch.matches(&context, Some("anyservice")));
163
164 let context = ObjectContext {
166 usecase: "other".to_string(),
167 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
168 };
169 assert!(!switch.matches(&context, Some("anyservice")));
170 }
171
172 #[test]
173 fn test_matches_scopes() {
174 let switch = Killswitch {
175 usecase: None,
176 scopes: BTreeMap::from([
177 ("org".to_string(), "123".to_string()),
178 ("project".to_string(), "456".to_string()),
179 ]),
180 service: None,
181 service_matcher: OnceLock::new(),
182 };
183
184 let context = ObjectContext {
186 usecase: "any".to_string(),
187 scopes: Scopes::from_iter([
188 Scope::create("org", "123").unwrap(),
189 Scope::create("project", "456").unwrap(),
190 Scope::create("extra", "789").unwrap(),
191 ]),
192 };
193 assert!(switch.matches(&context, Some("anyservice")));
194
195 let context = ObjectContext {
197 usecase: "any".to_string(),
198 scopes: Scopes::from_iter([
199 Scope::create("org", "123").unwrap(),
200 Scope::create("project", "999").unwrap(),
201 ]),
202 };
203 assert!(!switch.matches(&context, Some("anyservice")));
204
205 let context = ObjectContext {
207 usecase: "any".to_string(),
208 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
209 };
210 assert!(!switch.matches(&context, Some("anyservice")));
211 }
212
213 #[test]
214 fn test_matches_full() {
215 let switch = Killswitch {
216 usecase: Some("test".to_string()),
217 scopes: BTreeMap::from([("org".to_string(), "123".to_string())]),
218 service: Some("myservice-*".to_string()),
219 service_matcher: OnceLock::new(),
220 };
221
222 let context = ObjectContext {
224 usecase: "test".to_string(),
225 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
226 };
227 assert!(switch.matches(&context, Some("myservice-prod")));
228
229 let context = ObjectContext {
231 usecase: "other".to_string(),
232 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
233 };
234 assert!(!switch.matches(&context, Some("myservice-prod")));
235
236 let context = ObjectContext {
238 usecase: "test".to_string(),
239 scopes: Scopes::from_iter([Scope::create("org", "999").unwrap()]),
240 };
241 assert!(!switch.matches(&context, Some("myservice-prod")));
242
243 let context = ObjectContext {
245 usecase: "test".to_string(),
246 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
247 };
248 assert!(!switch.matches(&context, Some("otherservice")));
249
250 let context = ObjectContext {
252 usecase: "test".to_string(),
253 scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
254 };
255 assert!(!switch.matches(&context, None));
256 }
257
258 #[test]
259 fn test_matches_service_exact() {
260 let switch = Killswitch {
261 usecase: None,
262 scopes: BTreeMap::new(),
263 service: Some("myservice".to_string()),
264 service_matcher: OnceLock::new(),
265 };
266
267 let context = ObjectContext {
268 usecase: "any".to_string(),
269 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
270 };
271
272 assert!(switch.matches(&context, Some("myservice")));
273 assert!(!switch.matches(&context, Some("otherservice")));
274 assert!(!switch.matches(&context, None));
275 }
276
277 #[test]
278 fn test_matches_service_glob() {
279 let switch = Killswitch {
280 usecase: None,
281 scopes: BTreeMap::new(),
282 service: Some("myservice-*".to_string()),
283 service_matcher: OnceLock::new(),
284 };
285
286 let context = ObjectContext {
287 usecase: "any".to_string(),
288 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
289 };
290
291 assert!(switch.matches(&context, Some("myservice-prod")));
293 assert!(switch.matches(&context, Some("myservice-dev")));
294 assert!(switch.matches(&context, Some("myservice-staging")));
295
296 assert!(!switch.matches(&context, Some("otherservice")));
298 assert!(!switch.matches(&context, Some("otherservice-prod")));
299
300 assert!(!switch.matches(&context, Some("myservice")));
302 }
303
304 #[test]
305 fn test_matches_service_invalid_glob() {
306 let switch = Killswitch {
307 usecase: None,
308 scopes: BTreeMap::new(),
309 service: Some("[invalid".to_string()), service_matcher: OnceLock::new(),
311 };
312
313 let context = ObjectContext {
314 usecase: "any".to_string(),
315 scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
316 };
317
318 assert!(!switch.matches(&context, Some("anyservice")));
320 assert!(!switch.matches(&context, Some("[invalid")));
321 }
322}