1use serde_json::Value;
8use serde_json::json;
9use std::collections::HashMap;
10use std::fs;
11use std::panic::{self, AssertUnwindSafe};
12use std::path::{Path, PathBuf};
13use std::sync::RwLock;
14use std::sync::{
15 Arc,
16 atomic::{AtomicBool, Ordering},
17};
18use std::thread::{self, JoinHandle};
19use std::time::Duration;
20
21const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
23const SCHEMA_FILE_NAME: &str = "schema.json";
24const VALUES_FILE_NAME: &str = "values.json";
25
26const POLLING_DELAY: u64 = 5;
28
29pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
31
32pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
34
35pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
37
38pub fn resolve_options_dir() -> PathBuf {
43 if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
44 return PathBuf::from(dir);
45 }
46
47 let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
48 if prod_path.exists() {
49 return prod_path;
50 }
51
52 PathBuf::from(LOCAL_OPTIONS_DIR)
53}
54
55pub type ValidationResult<T> = Result<T, ValidationError>;
57
58pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
60
61#[derive(Debug, thiserror::Error)]
63pub enum ValidationError {
64 #[error("Schema error in {file}: {message}")]
65 SchemaError { file: PathBuf, message: String },
66
67 #[error("Value error for {namespace}: {errors}")]
68 ValueError { namespace: String, errors: String },
69
70 #[error("Unknown namespace: {0}")]
71 UnknownNamespace(String),
72
73 #[error("Internal error: {0}")]
74 InternalError(String),
75
76 #[error("Failed to read file: {0}")]
77 FileRead(#[from] std::io::Error),
78
79 #[error("Failed to parse JSON: {0}")]
80 JSONParse(#[from] serde_json::Error),
81
82 #[error("{} validation error(s)", .0.len())]
83 ValidationErrors(Vec<ValidationError>),
84
85 #[error("Invalid {label} '{name}': {reason}")]
86 InvalidName {
87 label: String,
88 name: String,
89 reason: String,
90 },
91}
92
93pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
95 if let Some(c) = name
96 .chars()
97 .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
98 {
99 return Err(ValidationError::InvalidName {
100 label: label.to_string(),
101 name: name.to_string(),
102 reason: format!(
103 "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
104 c
105 ),
106 });
107 }
108 if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
109 || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
110 {
111 return Err(ValidationError::InvalidName {
112 label: label.to_string(),
113 name: name.to_string(),
114 reason: "must start and end with alphanumeric".to_string(),
115 });
116 }
117 Ok(())
118}
119
120#[derive(Debug, Clone)]
122pub struct OptionMetadata {
123 pub option_type: String,
124 pub default: Value,
125}
126
127pub struct NamespaceSchema {
129 pub namespace: String,
130 pub options: HashMap<String, OptionMetadata>,
131 validator: jsonschema::Validator,
132}
133
134impl NamespaceSchema {
135 pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
143 let output = self.validator.evaluate(values);
144 if output.flag().valid {
145 Ok(())
146 } else {
147 let errors: Vec<String> = output.iter_errors().map(|e| e.error.to_string()).collect();
148 Err(ValidationError::ValueError {
149 namespace: self.namespace.clone(),
150 errors: errors.join(", "),
151 })
152 }
153 }
154
155 pub fn get_default(&self, key: &str) -> Option<&Value> {
158 self.options.get(key).map(|meta| &meta.default)
159 }
160}
161
162pub struct SchemaRegistry {
164 schemas: HashMap<String, Arc<NamespaceSchema>>,
165}
166
167impl SchemaRegistry {
168 pub fn new() -> Self {
170 Self {
171 schemas: HashMap::new(),
172 }
173 }
174
175 pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
185 let schemas = Self::load_all_schemas(schemas_dir)?;
186 Ok(Self { schemas })
187 }
188
189 pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
198 let schema = self
199 .schemas
200 .get(namespace)
201 .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
202
203 schema.validate_values(values)
204 }
205
206 fn load_all_schemas(
208 schemas_dir: &Path,
209 ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
210 let namespace_schema_value: Value =
212 serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
213 ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
214 })?;
215 let namespace_validator =
216 jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
217 ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
218 })?;
219
220 let mut schemas = HashMap::new();
221
222 for entry in fs::read_dir(schemas_dir)? {
224 let entry = entry?;
225
226 if !entry.file_type()?.is_dir() {
227 continue;
228 }
229
230 let namespace =
231 entry
232 .file_name()
233 .into_string()
234 .map_err(|_| ValidationError::SchemaError {
235 file: entry.path(),
236 message: "Directory name contains invalid UTF-8".to_string(),
237 })?;
238
239 validate_k8s_name_component(&namespace, "namespace name")?;
240
241 let schema_file = entry.path().join(SCHEMA_FILE_NAME);
242 let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
243 schemas.insert(namespace, schema);
244 }
245
246 Ok(schemas)
247 }
248
249 fn load_schema(
251 path: &Path,
252 namespace: &str,
253 namespace_validator: &jsonschema::Validator,
254 ) -> ValidationResult<Arc<NamespaceSchema>> {
255 let file = fs::File::open(path)?;
256 let schema_data: Value = serde_json::from_reader(file)?;
257
258 Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
259 Self::parse_schema(schema_data, namespace, path)
260 }
261
262 fn validate_with_namespace_schema(
264 schema_data: &Value,
265 path: &Path,
266 namespace_validator: &jsonschema::Validator,
267 ) -> ValidationResult<()> {
268 let output = namespace_validator.evaluate(schema_data);
269
270 if output.flag().valid {
271 Ok(())
272 } else {
273 let errors: Vec<String> = output
274 .iter_errors()
275 .map(|e| format!("Error: {}", e.error))
276 .collect();
277
278 Err(ValidationError::SchemaError {
279 file: path.to_path_buf(),
280 message: format!("Schema validation failed:\n{}", errors.join("\n")),
281 })
282 }
283 }
284
285 fn validate_default_type(
287 property_name: &str,
288 property_type: &str,
289 default_value: &Value,
290 path: &Path,
291 ) -> ValidationResult<()> {
292 let type_schema = serde_json::json!({
294 "type": property_type
295 });
296
297 jsonschema::validate(&type_schema, default_value).map_err(|e| {
299 ValidationError::SchemaError {
300 file: path.to_path_buf(),
301 message: format!(
302 "Property '{}': default value does not match type '{}': {}",
303 property_name, property_type, e
304 ),
305 }
306 })?;
307
308 Ok(())
309 }
310
311 fn parse_schema(
313 mut schema: Value,
314 namespace: &str,
315 path: &Path,
316 ) -> ValidationResult<Arc<NamespaceSchema>> {
317 if let Some(obj) = schema.as_object_mut() {
319 obj.insert("additionalProperties".to_string(), json!(false));
320 }
321
322 let validator =
324 jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
325 file: path.to_path_buf(),
326 message: format!("Failed to compile validator: {}", e),
327 })?;
328
329 let mut options = HashMap::new();
331 if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
332 for (prop_name, prop_value) in properties {
333 if let (Some(prop_type), Some(default_value)) = (
334 prop_value.get("type").and_then(|t| t.as_str()),
335 prop_value.get("default"),
336 ) {
337 Self::validate_default_type(prop_name, prop_type, default_value, path)?;
338 options.insert(
339 prop_name.clone(),
340 OptionMetadata {
341 option_type: prop_type.to_string(),
342 default: default_value.clone(),
343 },
344 );
345 }
346 }
347 }
348
349 Ok(Arc::new(NamespaceSchema {
350 namespace: namespace.to_string(),
351 options,
352 validator,
353 }))
354 }
355
356 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
358 self.schemas.get(namespace)
359 }
360
361 pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
363 &self.schemas
364 }
365
366 pub fn load_values_json(&self, values_dir: &Path) -> ValidationResult<ValuesByNamespace> {
370 let mut all_values = HashMap::new();
371
372 for namespace in self.schemas.keys() {
373 let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
374
375 if !values_file.exists() {
376 continue;
377 }
378
379 let values: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
380 self.validate_values(namespace, &values)?;
381
382 if let Value::Object(obj) = values {
383 let ns_values: HashMap<String, Value> = obj.into_iter().collect();
384 all_values.insert(namespace.clone(), ns_values);
385 }
386 }
387
388 Ok(all_values)
389 }
390}
391
392impl Default for SchemaRegistry {
393 fn default() -> Self {
394 Self::new()
395 }
396}
397
398pub struct ValuesWatcher {
413 stop_signal: Arc<AtomicBool>,
414 thread: Option<JoinHandle<()>>,
415}
416
417impl ValuesWatcher {
418 pub fn new(
420 values_path: &Path,
421 registry: Arc<SchemaRegistry>,
422 values: Arc<RwLock<ValuesByNamespace>>,
423 ) -> ValidationResult<Self> {
424 if fs::metadata(values_path).is_err() {
426 eprintln!("Values directory does not exist: {}", values_path.display());
427 }
428
429 let stop_signal = Arc::new(AtomicBool::new(false));
430
431 let thread_signal = Arc::clone(&stop_signal);
432 let thread_path = values_path.to_path_buf();
433 let thread_registry = Arc::clone(®istry);
434 let thread_values = Arc::clone(&values);
435 let thread = thread::Builder::new()
436 .name("sentry-options-watcher".into())
437 .spawn(move || {
438 let result = panic::catch_unwind(AssertUnwindSafe(|| {
439 Self::run(thread_signal, thread_path, thread_registry, thread_values);
440 }));
441 if let Err(e) = result {
442 eprintln!("Watcher thread panicked with: {:?}", e);
443 }
444 })?;
445
446 Ok(Self {
447 stop_signal,
448 thread: Some(thread),
449 })
450 }
451
452 fn run(
457 stop_signal: Arc<AtomicBool>,
458 values_path: PathBuf,
459 registry: Arc<SchemaRegistry>,
460 values: Arc<RwLock<ValuesByNamespace>>,
461 ) {
462 let mut last_mtime = Self::get_mtime(&values_path);
463
464 while !stop_signal.load(Ordering::Relaxed) {
465 if let Some(current_mtime) = Self::get_mtime(&values_path)
467 && Some(current_mtime) != last_mtime
468 {
469 Self::reload_values(&values_path, ®istry, &values);
470 last_mtime = Some(current_mtime);
471 }
472
473 thread::sleep(Duration::from_secs(POLLING_DELAY));
474 }
475 }
476
477 fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
480 let mut latest_mtime = None;
481
482 let entries = match fs::read_dir(values_dir) {
483 Ok(e) => e,
484 Err(e) => {
485 eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
486 return None;
487 }
488 };
489
490 for entry in entries.flatten() {
491 if !entry
493 .file_type()
494 .map(|file_type| file_type.is_dir())
495 .unwrap_or(false)
496 {
497 continue;
498 }
499
500 let values_file = entry.path().join(VALUES_FILE_NAME);
501 if let Ok(metadata) = fs::metadata(&values_file)
502 && let Ok(mtime) = metadata.modified()
503 && latest_mtime.is_none_or(|latest| mtime > latest)
504 {
505 latest_mtime = Some(mtime);
506 }
507 }
508
509 latest_mtime
510 }
511
512 fn reload_values(
514 values_path: &Path,
515 registry: &SchemaRegistry,
516 values: &Arc<RwLock<ValuesByNamespace>>,
517 ) {
518 match registry.load_values_json(values_path) {
519 Ok(new_values) => {
520 Self::update_values(values, new_values);
521 }
523 Err(e) => {
524 eprintln!(
525 "Failed to reload values from {}: {}",
526 values_path.display(),
527 e
528 );
529 }
530 }
531 }
532
533 fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
535 let mut guard = values.write().unwrap();
537 *guard = new_values;
538 }
539
540 pub fn stop(&mut self) {
543 self.stop_signal.store(true, Ordering::Relaxed);
544 if let Some(thread) = self.thread.take() {
545 let _ = thread.join();
546 }
547 }
548
549 pub fn is_alive(&self) -> bool {
551 self.thread.as_ref().is_some_and(|t| !t.is_finished())
552 }
553}
554
555impl Drop for ValuesWatcher {
556 fn drop(&mut self) {
557 self.stop();
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use tempfile::TempDir;
565
566 fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
567 let schema_dir = temp_dir.path().join(namespace);
568 fs::create_dir_all(&schema_dir).unwrap();
569 let schema_file = schema_dir.join("schema.json");
570 fs::write(&schema_file, schema_json).unwrap();
571 schema_file
572 }
573
574 #[test]
575 fn test_validate_k8s_name_component_valid() {
576 assert!(validate_k8s_name_component("relay", "namespace").is_ok());
577 assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
578 assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
579 assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
580 }
581
582 #[test]
583 fn test_validate_k8s_name_component_rejects_uppercase() {
584 let result = validate_k8s_name_component("MyService", "namespace");
585 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
586 assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
587 }
588
589 #[test]
590 fn test_validate_k8s_name_component_rejects_underscore() {
591 let result = validate_k8s_name_component("my_service", "target");
592 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
593 assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
594 }
595
596 #[test]
597 fn test_validate_k8s_name_component_rejects_leading_hyphen() {
598 let result = validate_k8s_name_component("-service", "namespace");
599 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
600 assert!(
601 result
602 .unwrap_err()
603 .to_string()
604 .contains("start and end with alphanumeric")
605 );
606 }
607
608 #[test]
609 fn test_validate_k8s_name_component_rejects_trailing_dot() {
610 let result = validate_k8s_name_component("service.", "namespace");
611 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
612 assert!(
613 result
614 .unwrap_err()
615 .to_string()
616 .contains("start and end with alphanumeric")
617 );
618 }
619
620 #[test]
621 fn test_load_schema_valid() {
622 let temp_dir = TempDir::new().unwrap();
623 create_test_schema(
624 &temp_dir,
625 "test",
626 r#"{
627 "version": "1.0",
628 "type": "object",
629 "properties": {
630 "test-key": {
631 "type": "string",
632 "default": "test",
633 "description": "Test option"
634 }
635 }
636 }"#,
637 );
638
639 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
640 }
641
642 #[test]
643 fn test_load_schema_missing_version() {
644 let temp_dir = TempDir::new().unwrap();
645 create_test_schema(
646 &temp_dir,
647 "test",
648 r#"{
649 "type": "object",
650 "properties": {}
651 }"#,
652 );
653
654 let result = SchemaRegistry::from_directory(temp_dir.path());
655 assert!(result.is_err());
656 match result {
657 Err(ValidationError::SchemaError { message, .. }) => {
658 assert!(message.contains(
659 "Schema validation failed:
660Error: \"version\" is a required property"
661 ));
662 }
663 _ => panic!("Expected SchemaError for missing version"),
664 }
665 }
666
667 #[test]
668 fn test_unknown_namespace() {
669 let temp_dir = TempDir::new().unwrap();
670 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
671
672 let result = registry.validate_values("unknown", &json!({}));
673 assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
674 }
675
676 #[test]
677 fn test_multiple_namespaces() {
678 let temp_dir = TempDir::new().unwrap();
679 create_test_schema(
680 &temp_dir,
681 "ns1",
682 r#"{
683 "version": "1.0",
684 "type": "object",
685 "properties": {
686 "opt1": {
687 "type": "string",
688 "default": "default1",
689 "description": "First option"
690 }
691 }
692 }"#,
693 );
694 create_test_schema(
695 &temp_dir,
696 "ns2",
697 r#"{
698 "version": "2.0",
699 "type": "object",
700 "properties": {
701 "opt2": {
702 "type": "integer",
703 "default": 42,
704 "description": "Second option"
705 }
706 }
707 }"#,
708 );
709
710 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
711 assert!(registry.schemas.contains_key("ns1"));
712 assert!(registry.schemas.contains_key("ns2"));
713 }
714
715 #[test]
716 fn test_invalid_default_type() {
717 let temp_dir = TempDir::new().unwrap();
718 create_test_schema(
719 &temp_dir,
720 "test",
721 r#"{
722 "version": "1.0",
723 "type": "object",
724 "properties": {
725 "bad-default": {
726 "type": "integer",
727 "default": "not-a-number",
728 "description": "A bad default value"
729 }
730 }
731 }"#,
732 );
733
734 let result = SchemaRegistry::from_directory(temp_dir.path());
735 assert!(result.is_err());
736 match result {
737 Err(ValidationError::SchemaError { message, .. }) => {
738 assert!(message.contains("Property 'bad-default': default value does not match type 'integer': \"not-a-number\" is not of type \"integer\""));
739 }
740 _ => panic!("Expected SchemaError for invalid default type"),
741 }
742 }
743
744 #[test]
745 fn test_extra_properties() {
746 let temp_dir = TempDir::new().unwrap();
747 create_test_schema(
748 &temp_dir,
749 "test",
750 r#"{
751 "version": "1.0",
752 "type": "object",
753 "properties": {
754 "bad-property": {
755 "type": "integer",
756 "default": 0,
757 "description": "Test property",
758 "extra": "property"
759 }
760 }
761 }"#,
762 );
763
764 let result = SchemaRegistry::from_directory(temp_dir.path());
765 assert!(result.is_err());
766 match result {
767 Err(ValidationError::SchemaError { message, .. }) => {
768 assert!(
769 message
770 .contains("Additional properties are not allowed ('extra' was unexpected)")
771 );
772 }
773 _ => panic!("Expected SchemaError for extra properties"),
774 }
775 }
776
777 #[test]
778 fn test_missing_description() {
779 let temp_dir = TempDir::new().unwrap();
780 create_test_schema(
781 &temp_dir,
782 "test",
783 r#"{
784 "version": "1.0",
785 "type": "object",
786 "properties": {
787 "missing-desc": {
788 "type": "string",
789 "default": "test"
790 }
791 }
792 }"#,
793 );
794
795 let result = SchemaRegistry::from_directory(temp_dir.path());
796 assert!(result.is_err());
797 match result {
798 Err(ValidationError::SchemaError { message, .. }) => {
799 assert!(message.contains("\"description\" is a required property"));
800 }
801 _ => panic!("Expected SchemaError for missing description"),
802 }
803 }
804
805 #[test]
806 fn test_invalid_directory_structure() {
807 let temp_dir = TempDir::new().unwrap();
808 let schema_dir = temp_dir.path().join("missing-schema");
810 fs::create_dir_all(&schema_dir).unwrap();
811
812 let result = SchemaRegistry::from_directory(temp_dir.path());
813 assert!(result.is_err());
814 match result {
815 Err(ValidationError::FileRead(..)) => {
816 }
818 _ => panic!("Expected FileRead error for missing schema.json"),
819 }
820 }
821
822 #[test]
823 fn test_get_default() {
824 let temp_dir = TempDir::new().unwrap();
825 create_test_schema(
826 &temp_dir,
827 "test",
828 r#"{
829 "version": "1.0",
830 "type": "object",
831 "properties": {
832 "string_opt": {
833 "type": "string",
834 "default": "hello",
835 "description": "A string option"
836 },
837 "int_opt": {
838 "type": "integer",
839 "default": 42,
840 "description": "An integer option"
841 }
842 }
843 }"#,
844 );
845
846 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
847 let schema = registry.get("test").unwrap();
848
849 assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
850 assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
851 assert_eq!(schema.get_default("unknown"), None);
852 }
853
854 #[test]
855 fn test_validate_values_valid() {
856 let temp_dir = TempDir::new().unwrap();
857 create_test_schema(
858 &temp_dir,
859 "test",
860 r#"{
861 "version": "1.0",
862 "type": "object",
863 "properties": {
864 "enabled": {
865 "type": "boolean",
866 "default": false,
867 "description": "Enable feature"
868 }
869 }
870 }"#,
871 );
872
873 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
874 let result = registry.validate_values("test", &json!({"enabled": true}));
875 assert!(result.is_ok());
876 }
877
878 #[test]
879 fn test_validate_values_invalid_type() {
880 let temp_dir = TempDir::new().unwrap();
881 create_test_schema(
882 &temp_dir,
883 "test",
884 r#"{
885 "version": "1.0",
886 "type": "object",
887 "properties": {
888 "count": {
889 "type": "integer",
890 "default": 0,
891 "description": "Count"
892 }
893 }
894 }"#,
895 );
896
897 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
898 let result = registry.validate_values("test", &json!({"count": "not a number"}));
899 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
900 }
901
902 #[test]
903 fn test_validate_values_unknown_option() {
904 let temp_dir = TempDir::new().unwrap();
905 create_test_schema(
906 &temp_dir,
907 "test",
908 r#"{
909 "version": "1.0",
910 "type": "object",
911 "properties": {
912 "known_option": {
913 "type": "string",
914 "default": "default",
915 "description": "A known option"
916 }
917 }
918 }"#,
919 );
920
921 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
922
923 let result = registry.validate_values("test", &json!({"known_option": "value"}));
925 assert!(result.is_ok());
926
927 let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
929 assert!(result.is_err());
930 match result {
931 Err(ValidationError::ValueError { errors, .. }) => {
932 assert!(errors.contains("Additional properties are not allowed"));
933 }
934 _ => panic!("Expected ValueError for unknown option"),
935 }
936 }
937
938 #[test]
939 fn test_load_values_json_valid() {
940 let temp_dir = TempDir::new().unwrap();
941 let schemas_dir = temp_dir.path().join("schemas");
942 let values_dir = temp_dir.path().join("values");
943
944 let schema_dir = schemas_dir.join("test");
945 fs::create_dir_all(&schema_dir).unwrap();
946 fs::write(
947 schema_dir.join("schema.json"),
948 r#"{
949 "version": "1.0",
950 "type": "object",
951 "properties": {
952 "enabled": {
953 "type": "boolean",
954 "default": false,
955 "description": "Enable feature"
956 },
957 "name": {
958 "type": "string",
959 "default": "default",
960 "description": "Name"
961 },
962 "count": {
963 "type": "integer",
964 "default": 0,
965 "description": "Count"
966 },
967 "rate": {
968 "type": "number",
969 "default": 0.0,
970 "description": "Rate"
971 }
972 }
973 }"#,
974 )
975 .unwrap();
976
977 let test_values_dir = values_dir.join("test");
978 fs::create_dir_all(&test_values_dir).unwrap();
979 fs::write(
980 test_values_dir.join("values.json"),
981 r#"{
982 "enabled": true,
983 "name": "test-name",
984 "count": 42,
985 "rate": 0.75
986 }"#,
987 )
988 .unwrap();
989
990 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
991 let values = registry.load_values_json(&values_dir).unwrap();
992
993 assert_eq!(values.len(), 1);
994 assert_eq!(values["test"]["enabled"], json!(true));
995 assert_eq!(values["test"]["name"], json!("test-name"));
996 assert_eq!(values["test"]["count"], json!(42));
997 assert_eq!(values["test"]["rate"], json!(0.75));
998 }
999
1000 #[test]
1001 fn test_load_values_json_nonexistent_dir() {
1002 let temp_dir = TempDir::new().unwrap();
1003 create_test_schema(
1004 &temp_dir,
1005 "test",
1006 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1007 );
1008
1009 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1010 let values = registry
1011 .load_values_json(&temp_dir.path().join("nonexistent"))
1012 .unwrap();
1013
1014 assert!(values.is_empty());
1016 }
1017
1018 #[test]
1019 fn test_load_values_json_skips_missing_values_file() {
1020 let temp_dir = TempDir::new().unwrap();
1021 let schemas_dir = temp_dir.path().join("schemas");
1022 let values_dir = temp_dir.path().join("values");
1023
1024 let schema_dir1 = schemas_dir.join("with-values");
1026 fs::create_dir_all(&schema_dir1).unwrap();
1027 fs::write(
1028 schema_dir1.join("schema.json"),
1029 r#"{
1030 "version": "1.0",
1031 "type": "object",
1032 "properties": {
1033 "opt": {"type": "string", "default": "x", "description": "Opt"}
1034 }
1035 }"#,
1036 )
1037 .unwrap();
1038
1039 let schema_dir2 = schemas_dir.join("without-values");
1040 fs::create_dir_all(&schema_dir2).unwrap();
1041 fs::write(
1042 schema_dir2.join("schema.json"),
1043 r#"{
1044 "version": "1.0",
1045 "type": "object",
1046 "properties": {
1047 "opt": {"type": "string", "default": "x", "description": "Opt"}
1048 }
1049 }"#,
1050 )
1051 .unwrap();
1052
1053 let with_values_dir = values_dir.join("with-values");
1055 fs::create_dir_all(&with_values_dir).unwrap();
1056 fs::write(with_values_dir.join("values.json"), r#"{"opt": "y"}"#).unwrap();
1057
1058 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1059 let values = registry.load_values_json(&values_dir).unwrap();
1060
1061 assert_eq!(values.len(), 1);
1062 assert!(values.contains_key("with-values"));
1063 assert!(!values.contains_key("without-values"));
1064 }
1065
1066 #[test]
1067 fn test_load_values_json_rejects_wrong_type() {
1068 let temp_dir = TempDir::new().unwrap();
1069 let schemas_dir = temp_dir.path().join("schemas");
1070 let values_dir = temp_dir.path().join("values");
1071
1072 let schema_dir = schemas_dir.join("test");
1073 fs::create_dir_all(&schema_dir).unwrap();
1074 fs::write(
1075 schema_dir.join("schema.json"),
1076 r#"{
1077 "version": "1.0",
1078 "type": "object",
1079 "properties": {
1080 "count": {"type": "integer", "default": 0, "description": "Count"}
1081 }
1082 }"#,
1083 )
1084 .unwrap();
1085
1086 let test_values_dir = values_dir.join("test");
1087 fs::create_dir_all(&test_values_dir).unwrap();
1088 fs::write(
1089 test_values_dir.join("values.json"),
1090 r#"{"count": "not-a-number"}"#,
1091 )
1092 .unwrap();
1093
1094 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1095 let result = registry.load_values_json(&values_dir);
1096
1097 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1098 }
1099
1100 mod watcher_tests {
1101 use super::*;
1102 use std::thread;
1103
1104 fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1106 let temp_dir = TempDir::new().unwrap();
1107 let schemas_dir = temp_dir.path().join("schemas");
1108 let values_dir = temp_dir.path().join("values");
1109
1110 let ns1_schema = schemas_dir.join("ns1");
1111 fs::create_dir_all(&ns1_schema).unwrap();
1112 fs::write(
1113 ns1_schema.join("schema.json"),
1114 r#"{
1115 "version": "1.0",
1116 "type": "object",
1117 "properties": {
1118 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1119 }
1120 }"#,
1121 )
1122 .unwrap();
1123
1124 let ns1_values = values_dir.join("ns1");
1125 fs::create_dir_all(&ns1_values).unwrap();
1126 fs::write(ns1_values.join("values.json"), r#"{"enabled": true}"#).unwrap();
1127
1128 let ns2_schema = schemas_dir.join("ns2");
1129 fs::create_dir_all(&ns2_schema).unwrap();
1130 fs::write(
1131 ns2_schema.join("schema.json"),
1132 r#"{
1133 "version": "1.0",
1134 "type": "object",
1135 "properties": {
1136 "count": {"type": "integer", "default": 0, "description": "Count"}
1137 }
1138 }"#,
1139 )
1140 .unwrap();
1141
1142 let ns2_values = values_dir.join("ns2");
1143 fs::create_dir_all(&ns2_values).unwrap();
1144 fs::write(ns2_values.join("values.json"), r#"{"count": 42}"#).unwrap();
1145
1146 (temp_dir, schemas_dir, values_dir)
1147 }
1148
1149 #[test]
1150 fn test_get_mtime_returns_most_recent() {
1151 let (_temp, _schemas, values_dir) = setup_watcher_test();
1152
1153 let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1155 assert!(mtime1.is_some());
1156
1157 thread::sleep(std::time::Duration::from_millis(10));
1159 fs::write(
1160 values_dir.join("ns1").join("values.json"),
1161 r#"{"enabled": false}"#,
1162 )
1163 .unwrap();
1164
1165 let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1167 assert!(mtime2.is_some());
1168 assert!(mtime2 > mtime1);
1169 }
1170
1171 #[test]
1172 fn test_get_mtime_with_missing_directory() {
1173 let temp = TempDir::new().unwrap();
1174 let nonexistent = temp.path().join("nonexistent");
1175
1176 let mtime = ValuesWatcher::get_mtime(&nonexistent);
1177 assert!(mtime.is_none());
1178 }
1179
1180 #[test]
1181 fn test_reload_values_updates_map() {
1182 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1183
1184 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1185 let initial_values = registry.load_values_json(&values_dir).unwrap();
1186 let values = Arc::new(RwLock::new(initial_values));
1187
1188 {
1190 let guard = values.read().unwrap();
1191 assert_eq!(guard["ns1"]["enabled"], json!(true));
1192 assert_eq!(guard["ns2"]["count"], json!(42));
1193 }
1194
1195 fs::write(
1197 values_dir.join("ns1").join("values.json"),
1198 r#"{"enabled": false}"#,
1199 )
1200 .unwrap();
1201 fs::write(
1202 values_dir.join("ns2").join("values.json"),
1203 r#"{"count": 100}"#,
1204 )
1205 .unwrap();
1206
1207 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1209
1210 {
1212 let guard = values.read().unwrap();
1213 assert_eq!(guard["ns1"]["enabled"], json!(false));
1214 assert_eq!(guard["ns2"]["count"], json!(100));
1215 }
1216 }
1217
1218 #[test]
1219 fn test_old_values_persist_with_invalid_data() {
1220 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1221
1222 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1223 let initial_values = registry.load_values_json(&values_dir).unwrap();
1224 let values = Arc::new(RwLock::new(initial_values));
1225
1226 let initial_enabled = {
1227 let guard = values.read().unwrap();
1228 guard["ns1"]["enabled"].clone()
1229 };
1230
1231 fs::write(
1233 values_dir.join("ns1").join("values.json"),
1234 r#"{"enabled": "not-a-boolean"}"#,
1235 )
1236 .unwrap();
1237
1238 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1239
1240 {
1242 let guard = values.read().unwrap();
1243 assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1244 }
1245 }
1246
1247 #[test]
1248 fn test_watcher_creation_and_termination() {
1249 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1250
1251 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1252 let initial_values = registry.load_values_json(&values_dir).unwrap();
1253 let values = Arc::new(RwLock::new(initial_values));
1254
1255 let mut watcher =
1256 ValuesWatcher::new(&values_dir, Arc::clone(®istry), Arc::clone(&values))
1257 .expect("Failed to create watcher");
1258
1259 assert!(watcher.is_alive());
1260 watcher.stop();
1261 assert!(!watcher.is_alive());
1262 }
1263 }
1264}