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> {
371 let mut all_values = HashMap::new();
372
373 for namespace in self.schemas.keys() {
374 let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
375
376 if !values_file.exists() {
377 continue;
378 }
379
380 let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
381
382 let values = parsed
383 .get("options")
384 .ok_or_else(|| ValidationError::ValueError {
385 namespace: namespace.clone(),
386 errors: "values.json must have an 'options' key".to_string(),
387 })?;
388
389 self.validate_values(namespace, values)?;
390
391 if let Value::Object(obj) = values.clone() {
392 let ns_values: HashMap<String, Value> = obj.into_iter().collect();
393 all_values.insert(namespace.clone(), ns_values);
394 }
395 }
396
397 Ok(all_values)
398 }
399}
400
401impl Default for SchemaRegistry {
402 fn default() -> Self {
403 Self::new()
404 }
405}
406
407pub struct ValuesWatcher {
422 stop_signal: Arc<AtomicBool>,
423 thread: Option<JoinHandle<()>>,
424}
425
426impl ValuesWatcher {
427 pub fn new(
429 values_path: &Path,
430 registry: Arc<SchemaRegistry>,
431 values: Arc<RwLock<ValuesByNamespace>>,
432 ) -> ValidationResult<Self> {
433 if fs::metadata(values_path).is_err() {
435 eprintln!("Values directory does not exist: {}", values_path.display());
436 }
437
438 let stop_signal = Arc::new(AtomicBool::new(false));
439
440 let thread_signal = Arc::clone(&stop_signal);
441 let thread_path = values_path.to_path_buf();
442 let thread_registry = Arc::clone(®istry);
443 let thread_values = Arc::clone(&values);
444 let thread = thread::Builder::new()
445 .name("sentry-options-watcher".into())
446 .spawn(move || {
447 let result = panic::catch_unwind(AssertUnwindSafe(|| {
448 Self::run(thread_signal, thread_path, thread_registry, thread_values);
449 }));
450 if let Err(e) = result {
451 eprintln!("Watcher thread panicked with: {:?}", e);
452 }
453 })?;
454
455 Ok(Self {
456 stop_signal,
457 thread: Some(thread),
458 })
459 }
460
461 fn run(
466 stop_signal: Arc<AtomicBool>,
467 values_path: PathBuf,
468 registry: Arc<SchemaRegistry>,
469 values: Arc<RwLock<ValuesByNamespace>>,
470 ) {
471 let mut last_mtime = Self::get_mtime(&values_path);
472
473 while !stop_signal.load(Ordering::Relaxed) {
474 if let Some(current_mtime) = Self::get_mtime(&values_path)
476 && Some(current_mtime) != last_mtime
477 {
478 Self::reload_values(&values_path, ®istry, &values);
479 last_mtime = Some(current_mtime);
480 }
481
482 thread::sleep(Duration::from_secs(POLLING_DELAY));
483 }
484 }
485
486 fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
489 let mut latest_mtime = None;
490
491 let entries = match fs::read_dir(values_dir) {
492 Ok(e) => e,
493 Err(e) => {
494 eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
495 return None;
496 }
497 };
498
499 for entry in entries.flatten() {
500 if !entry
502 .file_type()
503 .map(|file_type| file_type.is_dir())
504 .unwrap_or(false)
505 {
506 continue;
507 }
508
509 let values_file = entry.path().join(VALUES_FILE_NAME);
510 if let Ok(metadata) = fs::metadata(&values_file)
511 && let Ok(mtime) = metadata.modified()
512 && latest_mtime.is_none_or(|latest| mtime > latest)
513 {
514 latest_mtime = Some(mtime);
515 }
516 }
517
518 latest_mtime
519 }
520
521 fn reload_values(
523 values_path: &Path,
524 registry: &SchemaRegistry,
525 values: &Arc<RwLock<ValuesByNamespace>>,
526 ) {
527 match registry.load_values_json(values_path) {
528 Ok(new_values) => {
529 Self::update_values(values, new_values);
530 }
532 Err(e) => {
533 eprintln!(
534 "Failed to reload values from {}: {}",
535 values_path.display(),
536 e
537 );
538 }
539 }
540 }
541
542 fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
544 let mut guard = values.write().unwrap();
546 *guard = new_values;
547 }
548
549 pub fn stop(&mut self) {
552 self.stop_signal.store(true, Ordering::Relaxed);
553 if let Some(thread) = self.thread.take() {
554 let _ = thread.join();
555 }
556 }
557
558 pub fn is_alive(&self) -> bool {
560 self.thread.as_ref().is_some_and(|t| !t.is_finished())
561 }
562}
563
564impl Drop for ValuesWatcher {
565 fn drop(&mut self) {
566 self.stop();
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use tempfile::TempDir;
574
575 fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
576 let schema_dir = temp_dir.path().join(namespace);
577 fs::create_dir_all(&schema_dir).unwrap();
578 let schema_file = schema_dir.join("schema.json");
579 fs::write(&schema_file, schema_json).unwrap();
580 schema_file
581 }
582
583 #[test]
584 fn test_validate_k8s_name_component_valid() {
585 assert!(validate_k8s_name_component("relay", "namespace").is_ok());
586 assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
587 assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
588 assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
589 }
590
591 #[test]
592 fn test_validate_k8s_name_component_rejects_uppercase() {
593 let result = validate_k8s_name_component("MyService", "namespace");
594 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
595 assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
596 }
597
598 #[test]
599 fn test_validate_k8s_name_component_rejects_underscore() {
600 let result = validate_k8s_name_component("my_service", "target");
601 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
602 assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
603 }
604
605 #[test]
606 fn test_validate_k8s_name_component_rejects_leading_hyphen() {
607 let result = validate_k8s_name_component("-service", "namespace");
608 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
609 assert!(
610 result
611 .unwrap_err()
612 .to_string()
613 .contains("start and end with alphanumeric")
614 );
615 }
616
617 #[test]
618 fn test_validate_k8s_name_component_rejects_trailing_dot() {
619 let result = validate_k8s_name_component("service.", "namespace");
620 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
621 assert!(
622 result
623 .unwrap_err()
624 .to_string()
625 .contains("start and end with alphanumeric")
626 );
627 }
628
629 #[test]
630 fn test_load_schema_valid() {
631 let temp_dir = TempDir::new().unwrap();
632 create_test_schema(
633 &temp_dir,
634 "test",
635 r#"{
636 "version": "1.0",
637 "type": "object",
638 "properties": {
639 "test-key": {
640 "type": "string",
641 "default": "test",
642 "description": "Test option"
643 }
644 }
645 }"#,
646 );
647
648 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
649 }
650
651 #[test]
652 fn test_load_schema_missing_version() {
653 let temp_dir = TempDir::new().unwrap();
654 create_test_schema(
655 &temp_dir,
656 "test",
657 r#"{
658 "type": "object",
659 "properties": {}
660 }"#,
661 );
662
663 let result = SchemaRegistry::from_directory(temp_dir.path());
664 assert!(result.is_err());
665 match result {
666 Err(ValidationError::SchemaError { message, .. }) => {
667 assert!(message.contains(
668 "Schema validation failed:
669Error: \"version\" is a required property"
670 ));
671 }
672 _ => panic!("Expected SchemaError for missing version"),
673 }
674 }
675
676 #[test]
677 fn test_unknown_namespace() {
678 let temp_dir = TempDir::new().unwrap();
679 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
680
681 let result = registry.validate_values("unknown", &json!({}));
682 assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
683 }
684
685 #[test]
686 fn test_multiple_namespaces() {
687 let temp_dir = TempDir::new().unwrap();
688 create_test_schema(
689 &temp_dir,
690 "ns1",
691 r#"{
692 "version": "1.0",
693 "type": "object",
694 "properties": {
695 "opt1": {
696 "type": "string",
697 "default": "default1",
698 "description": "First option"
699 }
700 }
701 }"#,
702 );
703 create_test_schema(
704 &temp_dir,
705 "ns2",
706 r#"{
707 "version": "2.0",
708 "type": "object",
709 "properties": {
710 "opt2": {
711 "type": "integer",
712 "default": 42,
713 "description": "Second option"
714 }
715 }
716 }"#,
717 );
718
719 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
720 assert!(registry.schemas.contains_key("ns1"));
721 assert!(registry.schemas.contains_key("ns2"));
722 }
723
724 #[test]
725 fn test_invalid_default_type() {
726 let temp_dir = TempDir::new().unwrap();
727 create_test_schema(
728 &temp_dir,
729 "test",
730 r#"{
731 "version": "1.0",
732 "type": "object",
733 "properties": {
734 "bad-default": {
735 "type": "integer",
736 "default": "not-a-number",
737 "description": "A bad default value"
738 }
739 }
740 }"#,
741 );
742
743 let result = SchemaRegistry::from_directory(temp_dir.path());
744 assert!(result.is_err());
745 match result {
746 Err(ValidationError::SchemaError { message, .. }) => {
747 assert!(message.contains("Property 'bad-default': default value does not match type 'integer': \"not-a-number\" is not of type \"integer\""));
748 }
749 _ => panic!("Expected SchemaError for invalid default type"),
750 }
751 }
752
753 #[test]
754 fn test_extra_properties() {
755 let temp_dir = TempDir::new().unwrap();
756 create_test_schema(
757 &temp_dir,
758 "test",
759 r#"{
760 "version": "1.0",
761 "type": "object",
762 "properties": {
763 "bad-property": {
764 "type": "integer",
765 "default": 0,
766 "description": "Test property",
767 "extra": "property"
768 }
769 }
770 }"#,
771 );
772
773 let result = SchemaRegistry::from_directory(temp_dir.path());
774 assert!(result.is_err());
775 match result {
776 Err(ValidationError::SchemaError { message, .. }) => {
777 assert!(
778 message
779 .contains("Additional properties are not allowed ('extra' was unexpected)")
780 );
781 }
782 _ => panic!("Expected SchemaError for extra properties"),
783 }
784 }
785
786 #[test]
787 fn test_missing_description() {
788 let temp_dir = TempDir::new().unwrap();
789 create_test_schema(
790 &temp_dir,
791 "test",
792 r#"{
793 "version": "1.0",
794 "type": "object",
795 "properties": {
796 "missing-desc": {
797 "type": "string",
798 "default": "test"
799 }
800 }
801 }"#,
802 );
803
804 let result = SchemaRegistry::from_directory(temp_dir.path());
805 assert!(result.is_err());
806 match result {
807 Err(ValidationError::SchemaError { message, .. }) => {
808 assert!(message.contains("\"description\" is a required property"));
809 }
810 _ => panic!("Expected SchemaError for missing description"),
811 }
812 }
813
814 #[test]
815 fn test_invalid_directory_structure() {
816 let temp_dir = TempDir::new().unwrap();
817 let schema_dir = temp_dir.path().join("missing-schema");
819 fs::create_dir_all(&schema_dir).unwrap();
820
821 let result = SchemaRegistry::from_directory(temp_dir.path());
822 assert!(result.is_err());
823 match result {
824 Err(ValidationError::FileRead(..)) => {
825 }
827 _ => panic!("Expected FileRead error for missing schema.json"),
828 }
829 }
830
831 #[test]
832 fn test_get_default() {
833 let temp_dir = TempDir::new().unwrap();
834 create_test_schema(
835 &temp_dir,
836 "test",
837 r#"{
838 "version": "1.0",
839 "type": "object",
840 "properties": {
841 "string_opt": {
842 "type": "string",
843 "default": "hello",
844 "description": "A string option"
845 },
846 "int_opt": {
847 "type": "integer",
848 "default": 42,
849 "description": "An integer option"
850 }
851 }
852 }"#,
853 );
854
855 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
856 let schema = registry.get("test").unwrap();
857
858 assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
859 assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
860 assert_eq!(schema.get_default("unknown"), None);
861 }
862
863 #[test]
864 fn test_validate_values_valid() {
865 let temp_dir = TempDir::new().unwrap();
866 create_test_schema(
867 &temp_dir,
868 "test",
869 r#"{
870 "version": "1.0",
871 "type": "object",
872 "properties": {
873 "enabled": {
874 "type": "boolean",
875 "default": false,
876 "description": "Enable feature"
877 }
878 }
879 }"#,
880 );
881
882 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
883 let result = registry.validate_values("test", &json!({"enabled": true}));
884 assert!(result.is_ok());
885 }
886
887 #[test]
888 fn test_validate_values_invalid_type() {
889 let temp_dir = TempDir::new().unwrap();
890 create_test_schema(
891 &temp_dir,
892 "test",
893 r#"{
894 "version": "1.0",
895 "type": "object",
896 "properties": {
897 "count": {
898 "type": "integer",
899 "default": 0,
900 "description": "Count"
901 }
902 }
903 }"#,
904 );
905
906 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
907 let result = registry.validate_values("test", &json!({"count": "not a number"}));
908 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
909 }
910
911 #[test]
912 fn test_validate_values_unknown_option() {
913 let temp_dir = TempDir::new().unwrap();
914 create_test_schema(
915 &temp_dir,
916 "test",
917 r#"{
918 "version": "1.0",
919 "type": "object",
920 "properties": {
921 "known_option": {
922 "type": "string",
923 "default": "default",
924 "description": "A known option"
925 }
926 }
927 }"#,
928 );
929
930 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
931
932 let result = registry.validate_values("test", &json!({"known_option": "value"}));
934 assert!(result.is_ok());
935
936 let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
938 assert!(result.is_err());
939 match result {
940 Err(ValidationError::ValueError { errors, .. }) => {
941 assert!(errors.contains("Additional properties are not allowed"));
942 }
943 _ => panic!("Expected ValueError for unknown option"),
944 }
945 }
946
947 #[test]
948 fn test_load_values_json_valid() {
949 let temp_dir = TempDir::new().unwrap();
950 let schemas_dir = temp_dir.path().join("schemas");
951 let values_dir = temp_dir.path().join("values");
952
953 let schema_dir = schemas_dir.join("test");
954 fs::create_dir_all(&schema_dir).unwrap();
955 fs::write(
956 schema_dir.join("schema.json"),
957 r#"{
958 "version": "1.0",
959 "type": "object",
960 "properties": {
961 "enabled": {
962 "type": "boolean",
963 "default": false,
964 "description": "Enable feature"
965 },
966 "name": {
967 "type": "string",
968 "default": "default",
969 "description": "Name"
970 },
971 "count": {
972 "type": "integer",
973 "default": 0,
974 "description": "Count"
975 },
976 "rate": {
977 "type": "number",
978 "default": 0.0,
979 "description": "Rate"
980 }
981 }
982 }"#,
983 )
984 .unwrap();
985
986 let test_values_dir = values_dir.join("test");
987 fs::create_dir_all(&test_values_dir).unwrap();
988 fs::write(
989 test_values_dir.join("values.json"),
990 r#"{
991 "options": {
992 "enabled": true,
993 "name": "test-name",
994 "count": 42,
995 "rate": 0.75
996 }
997 }"#,
998 )
999 .unwrap();
1000
1001 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1002 let values = registry.load_values_json(&values_dir).unwrap();
1003
1004 assert_eq!(values.len(), 1);
1005 assert_eq!(values["test"]["enabled"], json!(true));
1006 assert_eq!(values["test"]["name"], json!("test-name"));
1007 assert_eq!(values["test"]["count"], json!(42));
1008 assert_eq!(values["test"]["rate"], json!(0.75));
1009 }
1010
1011 #[test]
1012 fn test_load_values_json_nonexistent_dir() {
1013 let temp_dir = TempDir::new().unwrap();
1014 create_test_schema(
1015 &temp_dir,
1016 "test",
1017 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1018 );
1019
1020 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1021 let values = registry
1022 .load_values_json(&temp_dir.path().join("nonexistent"))
1023 .unwrap();
1024
1025 assert!(values.is_empty());
1027 }
1028
1029 #[test]
1030 fn test_load_values_json_skips_missing_values_file() {
1031 let temp_dir = TempDir::new().unwrap();
1032 let schemas_dir = temp_dir.path().join("schemas");
1033 let values_dir = temp_dir.path().join("values");
1034
1035 let schema_dir1 = schemas_dir.join("with-values");
1037 fs::create_dir_all(&schema_dir1).unwrap();
1038 fs::write(
1039 schema_dir1.join("schema.json"),
1040 r#"{
1041 "version": "1.0",
1042 "type": "object",
1043 "properties": {
1044 "opt": {"type": "string", "default": "x", "description": "Opt"}
1045 }
1046 }"#,
1047 )
1048 .unwrap();
1049
1050 let schema_dir2 = schemas_dir.join("without-values");
1051 fs::create_dir_all(&schema_dir2).unwrap();
1052 fs::write(
1053 schema_dir2.join("schema.json"),
1054 r#"{
1055 "version": "1.0",
1056 "type": "object",
1057 "properties": {
1058 "opt": {"type": "string", "default": "x", "description": "Opt"}
1059 }
1060 }"#,
1061 )
1062 .unwrap();
1063
1064 let with_values_dir = values_dir.join("with-values");
1066 fs::create_dir_all(&with_values_dir).unwrap();
1067 fs::write(
1068 with_values_dir.join("values.json"),
1069 r#"{"options": {"opt": "y"}}"#,
1070 )
1071 .unwrap();
1072
1073 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1074 let values = registry.load_values_json(&values_dir).unwrap();
1075
1076 assert_eq!(values.len(), 1);
1077 assert!(values.contains_key("with-values"));
1078 assert!(!values.contains_key("without-values"));
1079 }
1080
1081 #[test]
1082 fn test_load_values_json_rejects_wrong_type() {
1083 let temp_dir = TempDir::new().unwrap();
1084 let schemas_dir = temp_dir.path().join("schemas");
1085 let values_dir = temp_dir.path().join("values");
1086
1087 let schema_dir = schemas_dir.join("test");
1088 fs::create_dir_all(&schema_dir).unwrap();
1089 fs::write(
1090 schema_dir.join("schema.json"),
1091 r#"{
1092 "version": "1.0",
1093 "type": "object",
1094 "properties": {
1095 "count": {"type": "integer", "default": 0, "description": "Count"}
1096 }
1097 }"#,
1098 )
1099 .unwrap();
1100
1101 let test_values_dir = values_dir.join("test");
1102 fs::create_dir_all(&test_values_dir).unwrap();
1103 fs::write(
1104 test_values_dir.join("values.json"),
1105 r#"{"options": {"count": "not-a-number"}}"#,
1106 )
1107 .unwrap();
1108
1109 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1110 let result = registry.load_values_json(&values_dir);
1111
1112 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1113 }
1114
1115 mod watcher_tests {
1116 use super::*;
1117 use std::thread;
1118
1119 fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1121 let temp_dir = TempDir::new().unwrap();
1122 let schemas_dir = temp_dir.path().join("schemas");
1123 let values_dir = temp_dir.path().join("values");
1124
1125 let ns1_schema = schemas_dir.join("ns1");
1126 fs::create_dir_all(&ns1_schema).unwrap();
1127 fs::write(
1128 ns1_schema.join("schema.json"),
1129 r#"{
1130 "version": "1.0",
1131 "type": "object",
1132 "properties": {
1133 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1134 }
1135 }"#,
1136 )
1137 .unwrap();
1138
1139 let ns1_values = values_dir.join("ns1");
1140 fs::create_dir_all(&ns1_values).unwrap();
1141 fs::write(
1142 ns1_values.join("values.json"),
1143 r#"{"options": {"enabled": true}}"#,
1144 )
1145 .unwrap();
1146
1147 let ns2_schema = schemas_dir.join("ns2");
1148 fs::create_dir_all(&ns2_schema).unwrap();
1149 fs::write(
1150 ns2_schema.join("schema.json"),
1151 r#"{
1152 "version": "1.0",
1153 "type": "object",
1154 "properties": {
1155 "count": {"type": "integer", "default": 0, "description": "Count"}
1156 }
1157 }"#,
1158 )
1159 .unwrap();
1160
1161 let ns2_values = values_dir.join("ns2");
1162 fs::create_dir_all(&ns2_values).unwrap();
1163 fs::write(
1164 ns2_values.join("values.json"),
1165 r#"{"options": {"count": 42}}"#,
1166 )
1167 .unwrap();
1168
1169 (temp_dir, schemas_dir, values_dir)
1170 }
1171
1172 #[test]
1173 fn test_get_mtime_returns_most_recent() {
1174 let (_temp, _schemas, values_dir) = setup_watcher_test();
1175
1176 let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1178 assert!(mtime1.is_some());
1179
1180 thread::sleep(std::time::Duration::from_millis(10));
1182 fs::write(
1183 values_dir.join("ns1").join("values.json"),
1184 r#"{"options": {"enabled": false}}"#,
1185 )
1186 .unwrap();
1187
1188 let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1190 assert!(mtime2.is_some());
1191 assert!(mtime2 > mtime1);
1192 }
1193
1194 #[test]
1195 fn test_get_mtime_with_missing_directory() {
1196 let temp = TempDir::new().unwrap();
1197 let nonexistent = temp.path().join("nonexistent");
1198
1199 let mtime = ValuesWatcher::get_mtime(&nonexistent);
1200 assert!(mtime.is_none());
1201 }
1202
1203 #[test]
1204 fn test_reload_values_updates_map() {
1205 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1206
1207 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1208 let initial_values = registry.load_values_json(&values_dir).unwrap();
1209 let values = Arc::new(RwLock::new(initial_values));
1210
1211 {
1213 let guard = values.read().unwrap();
1214 assert_eq!(guard["ns1"]["enabled"], json!(true));
1215 assert_eq!(guard["ns2"]["count"], json!(42));
1216 }
1217
1218 fs::write(
1220 values_dir.join("ns1").join("values.json"),
1221 r#"{"options": {"enabled": false}}"#,
1222 )
1223 .unwrap();
1224 fs::write(
1225 values_dir.join("ns2").join("values.json"),
1226 r#"{"options": {"count": 100}}"#,
1227 )
1228 .unwrap();
1229
1230 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1232
1233 {
1235 let guard = values.read().unwrap();
1236 assert_eq!(guard["ns1"]["enabled"], json!(false));
1237 assert_eq!(guard["ns2"]["count"], json!(100));
1238 }
1239 }
1240
1241 #[test]
1242 fn test_old_values_persist_with_invalid_data() {
1243 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1244
1245 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1246 let initial_values = registry.load_values_json(&values_dir).unwrap();
1247 let values = Arc::new(RwLock::new(initial_values));
1248
1249 let initial_enabled = {
1250 let guard = values.read().unwrap();
1251 guard["ns1"]["enabled"].clone()
1252 };
1253
1254 fs::write(
1256 values_dir.join("ns1").join("values.json"),
1257 r#"{"options": {"enabled": "not-a-boolean"}}"#,
1258 )
1259 .unwrap();
1260
1261 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1262
1263 {
1265 let guard = values.read().unwrap();
1266 assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1267 }
1268 }
1269
1270 #[test]
1271 fn test_watcher_creation_and_termination() {
1272 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1273
1274 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1275 let initial_values = registry.load_values_json(&values_dir).unwrap();
1276 let values = Arc::new(RwLock::new(initial_values));
1277
1278 let mut watcher =
1279 ValuesWatcher::new(&values_dir, Arc::clone(®istry), Arc::clone(&values))
1280 .expect("Failed to create watcher");
1281
1282 assert!(watcher.is_alive());
1283 watcher.stop();
1284 assert!(!watcher.is_alive());
1285 }
1286 }
1287}