1use chrono::{DateTime, Utc};
8use sentry::ClientOptions;
9use sentry::transports::DefaultTransportFactory;
10use serde_json::Value;
11use serde_json::json;
12use std::collections::HashMap;
13use std::fs;
14use std::panic::{self, AssertUnwindSafe};
15use std::path::{Path, PathBuf};
16use std::sync::RwLock;
17use std::sync::{
18 Arc, OnceLock,
19 atomic::{AtomicBool, Ordering},
20};
21use std::thread::{self, JoinHandle};
22use std::time::{Duration, Instant};
23
24const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
26const SCHEMA_FILE_NAME: &str = "schema.json";
27const VALUES_FILE_NAME: &str = "values.json";
28
29const POLLING_DELAY: u64 = 5;
31
32const SENTRY_OPTIONS_DSN: &str =
35 "https://d3598a07e9f23a9acee9e2718cfd17bd@o1.ingest.us.sentry.io/4510750163927040";
36
37static SENTRY_HUB: OnceLock<Arc<sentry::Hub>> = OnceLock::new();
40
41fn get_sentry_hub() -> &'static Arc<sentry::Hub> {
42 SENTRY_HUB.get_or_init(|| {
43 let client = Arc::new(sentry::Client::from((
44 SENTRY_OPTIONS_DSN,
45 ClientOptions {
46 traces_sample_rate: 1.0,
47 transport: Some(Arc::new(DefaultTransportFactory)),
49 ..Default::default()
50 },
51 )));
52 Arc::new(sentry::Hub::new(
53 Some(client),
54 Arc::new(sentry::Scope::default()),
55 ))
56 })
57}
58
59pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
61
62pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
64
65pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
67
68pub fn resolve_options_dir() -> PathBuf {
73 if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
74 return PathBuf::from(dir);
75 }
76
77 let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
78 if prod_path.exists() {
79 return prod_path;
80 }
81
82 PathBuf::from(LOCAL_OPTIONS_DIR)
83}
84
85pub type ValidationResult<T> = Result<T, ValidationError>;
87
88pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
90
91#[derive(Debug, thiserror::Error)]
93pub enum ValidationError {
94 #[error("Schema error in {file}: {message}")]
95 SchemaError { file: PathBuf, message: String },
96
97 #[error("Value error for {namespace}: {errors}")]
98 ValueError { namespace: String, errors: String },
99
100 #[error("Unknown namespace: {0}")]
101 UnknownNamespace(String),
102
103 #[error("Internal error: {0}")]
104 InternalError(String),
105
106 #[error("Failed to read file: {0}")]
107 FileRead(#[from] std::io::Error),
108
109 #[error("Failed to parse JSON: {0}")]
110 JSONParse(#[from] serde_json::Error),
111
112 #[error("{} validation error(s)", .0.len())]
113 ValidationErrors(Vec<ValidationError>),
114
115 #[error("Invalid {label} '{name}': {reason}")]
116 InvalidName {
117 label: String,
118 name: String,
119 reason: String,
120 },
121}
122
123pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
125 if let Some(c) = name
126 .chars()
127 .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
128 {
129 return Err(ValidationError::InvalidName {
130 label: label.to_string(),
131 name: name.to_string(),
132 reason: format!(
133 "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
134 c
135 ),
136 });
137 }
138 if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
139 || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
140 {
141 return Err(ValidationError::InvalidName {
142 label: label.to_string(),
143 name: name.to_string(),
144 reason: "must start and end with alphanumeric".to_string(),
145 });
146 }
147 Ok(())
148}
149
150#[derive(Debug, Clone)]
152pub struct OptionMetadata {
153 pub option_type: String,
154 pub default: Value,
155}
156
157pub struct NamespaceSchema {
159 pub namespace: String,
160 pub options: HashMap<String, OptionMetadata>,
161 validator: jsonschema::Validator,
162}
163
164impl NamespaceSchema {
165 pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
173 let output = self.validator.evaluate(values);
174 if output.flag().valid {
175 Ok(())
176 } else {
177 let errors: Vec<String> = output.iter_errors().map(|e| e.error.to_string()).collect();
178 Err(ValidationError::ValueError {
179 namespace: self.namespace.clone(),
180 errors: errors.join(", "),
181 })
182 }
183 }
184
185 pub fn get_default(&self, key: &str) -> Option<&Value> {
188 self.options.get(key).map(|meta| &meta.default)
189 }
190}
191
192pub struct SchemaRegistry {
194 schemas: HashMap<String, Arc<NamespaceSchema>>,
195}
196
197impl SchemaRegistry {
198 pub fn new() -> Self {
200 Self {
201 schemas: HashMap::new(),
202 }
203 }
204
205 pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
215 let schemas = Self::load_all_schemas(schemas_dir)?;
216 Ok(Self { schemas })
217 }
218
219 pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
228 let schema = self
229 .schemas
230 .get(namespace)
231 .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
232
233 schema.validate_values(values)
234 }
235
236 fn load_all_schemas(
238 schemas_dir: &Path,
239 ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
240 let namespace_schema_value: Value =
242 serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
243 ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
244 })?;
245 let namespace_validator =
246 jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
247 ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
248 })?;
249
250 let mut schemas = HashMap::new();
251
252 for entry in fs::read_dir(schemas_dir)? {
254 let entry = entry?;
255
256 if !entry.file_type()?.is_dir() {
257 continue;
258 }
259
260 let namespace =
261 entry
262 .file_name()
263 .into_string()
264 .map_err(|_| ValidationError::SchemaError {
265 file: entry.path(),
266 message: "Directory name contains invalid UTF-8".to_string(),
267 })?;
268
269 validate_k8s_name_component(&namespace, "namespace name")?;
270
271 let schema_file = entry.path().join(SCHEMA_FILE_NAME);
272 let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
273 schemas.insert(namespace, schema);
274 }
275
276 Ok(schemas)
277 }
278
279 fn load_schema(
281 path: &Path,
282 namespace: &str,
283 namespace_validator: &jsonschema::Validator,
284 ) -> ValidationResult<Arc<NamespaceSchema>> {
285 let file = fs::File::open(path)?;
286 let schema_data: Value = serde_json::from_reader(file)?;
287
288 Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
289 Self::parse_schema(schema_data, namespace, path)
290 }
291
292 fn validate_with_namespace_schema(
294 schema_data: &Value,
295 path: &Path,
296 namespace_validator: &jsonschema::Validator,
297 ) -> ValidationResult<()> {
298 let output = namespace_validator.evaluate(schema_data);
299
300 if output.flag().valid {
301 Ok(())
302 } else {
303 let errors: Vec<String> = output
304 .iter_errors()
305 .map(|e| format!("Error: {}", e.error))
306 .collect();
307
308 Err(ValidationError::SchemaError {
309 file: path.to_path_buf(),
310 message: format!("Schema validation failed:\n{}", errors.join("\n")),
311 })
312 }
313 }
314
315 fn validate_default_type(
317 property_name: &str,
318 property_type: &str,
319 default_value: &Value,
320 path: &Path,
321 ) -> ValidationResult<()> {
322 let type_schema = serde_json::json!({
324 "type": property_type
325 });
326
327 jsonschema::validate(&type_schema, default_value).map_err(|e| {
329 ValidationError::SchemaError {
330 file: path.to_path_buf(),
331 message: format!(
332 "Property '{}': default value does not match type '{}': {}",
333 property_name, property_type, e
334 ),
335 }
336 })?;
337
338 Ok(())
339 }
340
341 fn parse_schema(
343 mut schema: Value,
344 namespace: &str,
345 path: &Path,
346 ) -> ValidationResult<Arc<NamespaceSchema>> {
347 if let Some(obj) = schema.as_object_mut() {
349 obj.insert("additionalProperties".to_string(), json!(false));
350 }
351
352 let validator =
354 jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
355 file: path.to_path_buf(),
356 message: format!("Failed to compile validator: {}", e),
357 })?;
358
359 let mut options = HashMap::new();
361 if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
362 for (prop_name, prop_value) in properties {
363 if let (Some(prop_type), Some(default_value)) = (
364 prop_value.get("type").and_then(|t| t.as_str()),
365 prop_value.get("default"),
366 ) {
367 Self::validate_default_type(prop_name, prop_type, default_value, path)?;
368 options.insert(
369 prop_name.clone(),
370 OptionMetadata {
371 option_type: prop_type.to_string(),
372 default: default_value.clone(),
373 },
374 );
375 }
376 }
377 }
378
379 Ok(Arc::new(NamespaceSchema {
380 namespace: namespace.to_string(),
381 options,
382 validator,
383 }))
384 }
385
386 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
388 self.schemas.get(namespace)
389 }
390
391 pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
393 &self.schemas
394 }
395
396 pub fn load_values_json(
402 &self,
403 values_dir: &Path,
404 ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
405 let mut all_values = HashMap::new();
406 let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
407
408 for namespace in self.schemas.keys() {
409 let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
410
411 if !values_file.exists() {
412 continue;
413 }
414
415 let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
416
417 if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
419 generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
420 }
421
422 let values = parsed
423 .get("options")
424 .ok_or_else(|| ValidationError::ValueError {
425 namespace: namespace.clone(),
426 errors: "values.json must have an 'options' key".to_string(),
427 })?;
428
429 self.validate_values(namespace, values)?;
430
431 if let Value::Object(obj) = values.clone() {
432 let ns_values: HashMap<String, Value> = obj.into_iter().collect();
433 all_values.insert(namespace.clone(), ns_values);
434 }
435 }
436
437 Ok((all_values, generated_at_by_namespace))
438 }
439}
440
441impl Default for SchemaRegistry {
442 fn default() -> Self {
443 Self::new()
444 }
445}
446
447pub struct ValuesWatcher {
462 stop_signal: Arc<AtomicBool>,
463 thread: Option<JoinHandle<()>>,
464}
465
466impl ValuesWatcher {
467 pub fn new(
469 values_path: &Path,
470 registry: Arc<SchemaRegistry>,
471 values: Arc<RwLock<ValuesByNamespace>>,
472 ) -> ValidationResult<Self> {
473 if fs::metadata(values_path).is_err() {
475 eprintln!("Values directory does not exist: {}", values_path.display());
476 }
477
478 let stop_signal = Arc::new(AtomicBool::new(false));
479
480 let thread_signal = Arc::clone(&stop_signal);
481 let thread_path = values_path.to_path_buf();
482 let thread_registry = Arc::clone(®istry);
483 let thread_values = Arc::clone(&values);
484 let thread = thread::Builder::new()
485 .name("sentry-options-watcher".into())
486 .spawn(move || {
487 let result = panic::catch_unwind(AssertUnwindSafe(|| {
488 Self::run(thread_signal, thread_path, thread_registry, thread_values);
489 }));
490 if let Err(e) = result {
491 eprintln!("Watcher thread panicked with: {:?}", e);
492 }
493 })?;
494
495 Ok(Self {
496 stop_signal,
497 thread: Some(thread),
498 })
499 }
500
501 fn run(
506 stop_signal: Arc<AtomicBool>,
507 values_path: PathBuf,
508 registry: Arc<SchemaRegistry>,
509 values: Arc<RwLock<ValuesByNamespace>>,
510 ) {
511 let mut last_mtime = Self::get_mtime(&values_path);
512
513 while !stop_signal.load(Ordering::Relaxed) {
514 if let Some(current_mtime) = Self::get_mtime(&values_path)
516 && Some(current_mtime) != last_mtime
517 {
518 Self::reload_values(&values_path, ®istry, &values);
519 last_mtime = Some(current_mtime);
520 }
521
522 thread::sleep(Duration::from_secs(POLLING_DELAY));
523 }
524 }
525
526 fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
529 let mut latest_mtime = None;
530
531 let entries = match fs::read_dir(values_dir) {
532 Ok(e) => e,
533 Err(e) => {
534 eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
535 return None;
536 }
537 };
538
539 for entry in entries.flatten() {
540 if !entry
542 .file_type()
543 .map(|file_type| file_type.is_dir())
544 .unwrap_or(false)
545 {
546 continue;
547 }
548
549 let values_file = entry.path().join(VALUES_FILE_NAME);
550 if let Ok(metadata) = fs::metadata(&values_file)
551 && let Ok(mtime) = metadata.modified()
552 && latest_mtime.is_none_or(|latest| mtime > latest)
553 {
554 latest_mtime = Some(mtime);
555 }
556 }
557
558 latest_mtime
559 }
560
561 fn reload_values(
564 values_path: &Path,
565 registry: &SchemaRegistry,
566 values: &Arc<RwLock<ValuesByNamespace>>,
567 ) {
568 let reload_start = Instant::now();
569
570 match registry.load_values_json(values_path) {
571 Ok((new_values, generated_at_by_namespace)) => {
572 let namespaces: Vec<String> = new_values.keys().cloned().collect();
573 Self::update_values(values, new_values);
574
575 let reload_duration = reload_start.elapsed();
576 Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
577 }
578 Err(e) => {
579 eprintln!(
580 "Failed to reload values from {}: {}",
581 values_path.display(),
582 e
583 );
584 }
585 }
586 }
587
588 fn emit_reload_spans(
591 namespaces: &[String],
592 reload_duration: Duration,
593 generated_at_by_namespace: &HashMap<String, String>,
594 ) {
595 let hub = get_sentry_hub();
596 let applied_at = Utc::now();
597 let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
598
599 for namespace in namespaces {
600 let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
601 tx_ctx.set_sampled(true);
602
603 let transaction = hub.start_transaction(tx_ctx);
604 transaction.set_data("reload_duration_ms", reload_duration_ms.into());
605 transaction.set_data("applied_at", applied_at.to_rfc3339().into());
606
607 if let Some(ts) = generated_at_by_namespace.get(namespace) {
608 transaction.set_data("generated_at", ts.as_str().into());
609
610 if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
611 let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
612 .num_milliseconds() as f64
613 / 1000.0;
614 transaction.set_data("propagation_delay_secs", delay_secs.into());
615 }
616 }
617
618 transaction.finish();
619 }
620 }
621
622 fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
624 let mut guard = values.write().unwrap();
626 *guard = new_values;
627 }
628
629 pub fn stop(&mut self) {
632 self.stop_signal.store(true, Ordering::Relaxed);
633 if let Some(thread) = self.thread.take() {
634 let _ = thread.join();
635 }
636 }
637
638 pub fn is_alive(&self) -> bool {
640 self.thread.as_ref().is_some_and(|t| !t.is_finished())
641 }
642}
643
644impl Drop for ValuesWatcher {
645 fn drop(&mut self) {
646 self.stop();
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use tempfile::TempDir;
654
655 fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
656 let schema_dir = temp_dir.path().join(namespace);
657 fs::create_dir_all(&schema_dir).unwrap();
658 let schema_file = schema_dir.join("schema.json");
659 fs::write(&schema_file, schema_json).unwrap();
660 schema_file
661 }
662
663 #[test]
664 fn test_validate_k8s_name_component_valid() {
665 assert!(validate_k8s_name_component("relay", "namespace").is_ok());
666 assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
667 assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
668 assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
669 }
670
671 #[test]
672 fn test_validate_k8s_name_component_rejects_uppercase() {
673 let result = validate_k8s_name_component("MyService", "namespace");
674 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
675 assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
676 }
677
678 #[test]
679 fn test_validate_k8s_name_component_rejects_underscore() {
680 let result = validate_k8s_name_component("my_service", "target");
681 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
682 assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
683 }
684
685 #[test]
686 fn test_validate_k8s_name_component_rejects_leading_hyphen() {
687 let result = validate_k8s_name_component("-service", "namespace");
688 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
689 assert!(
690 result
691 .unwrap_err()
692 .to_string()
693 .contains("start and end with alphanumeric")
694 );
695 }
696
697 #[test]
698 fn test_validate_k8s_name_component_rejects_trailing_dot() {
699 let result = validate_k8s_name_component("service.", "namespace");
700 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
701 assert!(
702 result
703 .unwrap_err()
704 .to_string()
705 .contains("start and end with alphanumeric")
706 );
707 }
708
709 #[test]
710 fn test_load_schema_valid() {
711 let temp_dir = TempDir::new().unwrap();
712 create_test_schema(
713 &temp_dir,
714 "test",
715 r#"{
716 "version": "1.0",
717 "type": "object",
718 "properties": {
719 "test-key": {
720 "type": "string",
721 "default": "test",
722 "description": "Test option"
723 }
724 }
725 }"#,
726 );
727
728 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
729 }
730
731 #[test]
732 fn test_load_schema_missing_version() {
733 let temp_dir = TempDir::new().unwrap();
734 create_test_schema(
735 &temp_dir,
736 "test",
737 r#"{
738 "type": "object",
739 "properties": {}
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(
748 "Schema validation failed:
749Error: \"version\" is a required property"
750 ));
751 }
752 _ => panic!("Expected SchemaError for missing version"),
753 }
754 }
755
756 #[test]
757 fn test_unknown_namespace() {
758 let temp_dir = TempDir::new().unwrap();
759 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
760
761 let result = registry.validate_values("unknown", &json!({}));
762 assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
763 }
764
765 #[test]
766 fn test_multiple_namespaces() {
767 let temp_dir = TempDir::new().unwrap();
768 create_test_schema(
769 &temp_dir,
770 "ns1",
771 r#"{
772 "version": "1.0",
773 "type": "object",
774 "properties": {
775 "opt1": {
776 "type": "string",
777 "default": "default1",
778 "description": "First option"
779 }
780 }
781 }"#,
782 );
783 create_test_schema(
784 &temp_dir,
785 "ns2",
786 r#"{
787 "version": "2.0",
788 "type": "object",
789 "properties": {
790 "opt2": {
791 "type": "integer",
792 "default": 42,
793 "description": "Second option"
794 }
795 }
796 }"#,
797 );
798
799 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
800 assert!(registry.schemas.contains_key("ns1"));
801 assert!(registry.schemas.contains_key("ns2"));
802 }
803
804 #[test]
805 fn test_invalid_default_type() {
806 let temp_dir = TempDir::new().unwrap();
807 create_test_schema(
808 &temp_dir,
809 "test",
810 r#"{
811 "version": "1.0",
812 "type": "object",
813 "properties": {
814 "bad-default": {
815 "type": "integer",
816 "default": "not-a-number",
817 "description": "A bad default value"
818 }
819 }
820 }"#,
821 );
822
823 let result = SchemaRegistry::from_directory(temp_dir.path());
824 assert!(result.is_err());
825 match result {
826 Err(ValidationError::SchemaError { message, .. }) => {
827 assert!(message.contains("Property 'bad-default': default value does not match type 'integer': \"not-a-number\" is not of type \"integer\""));
828 }
829 _ => panic!("Expected SchemaError for invalid default type"),
830 }
831 }
832
833 #[test]
834 fn test_extra_properties() {
835 let temp_dir = TempDir::new().unwrap();
836 create_test_schema(
837 &temp_dir,
838 "test",
839 r#"{
840 "version": "1.0",
841 "type": "object",
842 "properties": {
843 "bad-property": {
844 "type": "integer",
845 "default": 0,
846 "description": "Test property",
847 "extra": "property"
848 }
849 }
850 }"#,
851 );
852
853 let result = SchemaRegistry::from_directory(temp_dir.path());
854 assert!(result.is_err());
855 match result {
856 Err(ValidationError::SchemaError { message, .. }) => {
857 assert!(
858 message
859 .contains("Additional properties are not allowed ('extra' was unexpected)")
860 );
861 }
862 _ => panic!("Expected SchemaError for extra properties"),
863 }
864 }
865
866 #[test]
867 fn test_missing_description() {
868 let temp_dir = TempDir::new().unwrap();
869 create_test_schema(
870 &temp_dir,
871 "test",
872 r#"{
873 "version": "1.0",
874 "type": "object",
875 "properties": {
876 "missing-desc": {
877 "type": "string",
878 "default": "test"
879 }
880 }
881 }"#,
882 );
883
884 let result = SchemaRegistry::from_directory(temp_dir.path());
885 assert!(result.is_err());
886 match result {
887 Err(ValidationError::SchemaError { message, .. }) => {
888 assert!(message.contains("\"description\" is a required property"));
889 }
890 _ => panic!("Expected SchemaError for missing description"),
891 }
892 }
893
894 #[test]
895 fn test_invalid_directory_structure() {
896 let temp_dir = TempDir::new().unwrap();
897 let schema_dir = temp_dir.path().join("missing-schema");
899 fs::create_dir_all(&schema_dir).unwrap();
900
901 let result = SchemaRegistry::from_directory(temp_dir.path());
902 assert!(result.is_err());
903 match result {
904 Err(ValidationError::FileRead(..)) => {
905 }
907 _ => panic!("Expected FileRead error for missing schema.json"),
908 }
909 }
910
911 #[test]
912 fn test_get_default() {
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 "string_opt": {
922 "type": "string",
923 "default": "hello",
924 "description": "A string option"
925 },
926 "int_opt": {
927 "type": "integer",
928 "default": 42,
929 "description": "An integer option"
930 }
931 }
932 }"#,
933 );
934
935 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
936 let schema = registry.get("test").unwrap();
937
938 assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
939 assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
940 assert_eq!(schema.get_default("unknown"), None);
941 }
942
943 #[test]
944 fn test_validate_values_valid() {
945 let temp_dir = TempDir::new().unwrap();
946 create_test_schema(
947 &temp_dir,
948 "test",
949 r#"{
950 "version": "1.0",
951 "type": "object",
952 "properties": {
953 "enabled": {
954 "type": "boolean",
955 "default": false,
956 "description": "Enable feature"
957 }
958 }
959 }"#,
960 );
961
962 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
963 let result = registry.validate_values("test", &json!({"enabled": true}));
964 assert!(result.is_ok());
965 }
966
967 #[test]
968 fn test_validate_values_invalid_type() {
969 let temp_dir = TempDir::new().unwrap();
970 create_test_schema(
971 &temp_dir,
972 "test",
973 r#"{
974 "version": "1.0",
975 "type": "object",
976 "properties": {
977 "count": {
978 "type": "integer",
979 "default": 0,
980 "description": "Count"
981 }
982 }
983 }"#,
984 );
985
986 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
987 let result = registry.validate_values("test", &json!({"count": "not a number"}));
988 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
989 }
990
991 #[test]
992 fn test_validate_values_unknown_option() {
993 let temp_dir = TempDir::new().unwrap();
994 create_test_schema(
995 &temp_dir,
996 "test",
997 r#"{
998 "version": "1.0",
999 "type": "object",
1000 "properties": {
1001 "known_option": {
1002 "type": "string",
1003 "default": "default",
1004 "description": "A known option"
1005 }
1006 }
1007 }"#,
1008 );
1009
1010 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1011
1012 let result = registry.validate_values("test", &json!({"known_option": "value"}));
1014 assert!(result.is_ok());
1015
1016 let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1018 assert!(result.is_err());
1019 match result {
1020 Err(ValidationError::ValueError { errors, .. }) => {
1021 assert!(errors.contains("Additional properties are not allowed"));
1022 }
1023 _ => panic!("Expected ValueError for unknown option"),
1024 }
1025 }
1026
1027 #[test]
1028 fn test_load_values_json_valid() {
1029 let temp_dir = TempDir::new().unwrap();
1030 let schemas_dir = temp_dir.path().join("schemas");
1031 let values_dir = temp_dir.path().join("values");
1032
1033 let schema_dir = schemas_dir.join("test");
1034 fs::create_dir_all(&schema_dir).unwrap();
1035 fs::write(
1036 schema_dir.join("schema.json"),
1037 r#"{
1038 "version": "1.0",
1039 "type": "object",
1040 "properties": {
1041 "enabled": {
1042 "type": "boolean",
1043 "default": false,
1044 "description": "Enable feature"
1045 },
1046 "name": {
1047 "type": "string",
1048 "default": "default",
1049 "description": "Name"
1050 },
1051 "count": {
1052 "type": "integer",
1053 "default": 0,
1054 "description": "Count"
1055 },
1056 "rate": {
1057 "type": "number",
1058 "default": 0.0,
1059 "description": "Rate"
1060 }
1061 }
1062 }"#,
1063 )
1064 .unwrap();
1065
1066 let test_values_dir = values_dir.join("test");
1067 fs::create_dir_all(&test_values_dir).unwrap();
1068 fs::write(
1069 test_values_dir.join("values.json"),
1070 r#"{
1071 "options": {
1072 "enabled": true,
1073 "name": "test-name",
1074 "count": 42,
1075 "rate": 0.75
1076 }
1077 }"#,
1078 )
1079 .unwrap();
1080
1081 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1082 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1083
1084 assert_eq!(values.len(), 1);
1085 assert_eq!(values["test"]["enabled"], json!(true));
1086 assert_eq!(values["test"]["name"], json!("test-name"));
1087 assert_eq!(values["test"]["count"], json!(42));
1088 assert_eq!(values["test"]["rate"], json!(0.75));
1089 assert!(generated_at_by_namespace.is_empty());
1090 }
1091
1092 #[test]
1093 fn test_load_values_json_nonexistent_dir() {
1094 let temp_dir = TempDir::new().unwrap();
1095 create_test_schema(
1096 &temp_dir,
1097 "test",
1098 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1099 );
1100
1101 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1102 let (values, generated_at_by_namespace) = registry
1103 .load_values_json(&temp_dir.path().join("nonexistent"))
1104 .unwrap();
1105
1106 assert!(values.is_empty());
1108 assert!(generated_at_by_namespace.is_empty());
1109 }
1110
1111 #[test]
1112 fn test_load_values_json_skips_missing_values_file() {
1113 let temp_dir = TempDir::new().unwrap();
1114 let schemas_dir = temp_dir.path().join("schemas");
1115 let values_dir = temp_dir.path().join("values");
1116
1117 let schema_dir1 = schemas_dir.join("with-values");
1119 fs::create_dir_all(&schema_dir1).unwrap();
1120 fs::write(
1121 schema_dir1.join("schema.json"),
1122 r#"{
1123 "version": "1.0",
1124 "type": "object",
1125 "properties": {
1126 "opt": {"type": "string", "default": "x", "description": "Opt"}
1127 }
1128 }"#,
1129 )
1130 .unwrap();
1131
1132 let schema_dir2 = schemas_dir.join("without-values");
1133 fs::create_dir_all(&schema_dir2).unwrap();
1134 fs::write(
1135 schema_dir2.join("schema.json"),
1136 r#"{
1137 "version": "1.0",
1138 "type": "object",
1139 "properties": {
1140 "opt": {"type": "string", "default": "x", "description": "Opt"}
1141 }
1142 }"#,
1143 )
1144 .unwrap();
1145
1146 let with_values_dir = values_dir.join("with-values");
1148 fs::create_dir_all(&with_values_dir).unwrap();
1149 fs::write(
1150 with_values_dir.join("values.json"),
1151 r#"{"options": {"opt": "y"}}"#,
1152 )
1153 .unwrap();
1154
1155 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1156 let (values, _) = registry.load_values_json(&values_dir).unwrap();
1157
1158 assert_eq!(values.len(), 1);
1159 assert!(values.contains_key("with-values"));
1160 assert!(!values.contains_key("without-values"));
1161 }
1162
1163 #[test]
1164 fn test_load_values_json_extracts_generated_at() {
1165 let temp_dir = TempDir::new().unwrap();
1166 let schemas_dir = temp_dir.path().join("schemas");
1167 let values_dir = temp_dir.path().join("values");
1168
1169 let schema_dir = schemas_dir.join("test");
1170 fs::create_dir_all(&schema_dir).unwrap();
1171 fs::write(
1172 schema_dir.join("schema.json"),
1173 r#"{
1174 "version": "1.0",
1175 "type": "object",
1176 "properties": {
1177 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1178 }
1179 }"#,
1180 )
1181 .unwrap();
1182
1183 let test_values_dir = values_dir.join("test");
1184 fs::create_dir_all(&test_values_dir).unwrap();
1185 fs::write(
1186 test_values_dir.join("values.json"),
1187 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1188 )
1189 .unwrap();
1190
1191 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1192 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1193
1194 assert_eq!(values["test"]["enabled"], json!(true));
1195 assert_eq!(
1196 generated_at_by_namespace.get("test"),
1197 Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1198 );
1199 }
1200
1201 #[test]
1202 fn test_load_values_json_rejects_wrong_type() {
1203 let temp_dir = TempDir::new().unwrap();
1204 let schemas_dir = temp_dir.path().join("schemas");
1205 let values_dir = temp_dir.path().join("values");
1206
1207 let schema_dir = schemas_dir.join("test");
1208 fs::create_dir_all(&schema_dir).unwrap();
1209 fs::write(
1210 schema_dir.join("schema.json"),
1211 r#"{
1212 "version": "1.0",
1213 "type": "object",
1214 "properties": {
1215 "count": {"type": "integer", "default": 0, "description": "Count"}
1216 }
1217 }"#,
1218 )
1219 .unwrap();
1220
1221 let test_values_dir = values_dir.join("test");
1222 fs::create_dir_all(&test_values_dir).unwrap();
1223 fs::write(
1224 test_values_dir.join("values.json"),
1225 r#"{"options": {"count": "not-a-number"}}"#,
1226 )
1227 .unwrap();
1228
1229 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1230 let result = registry.load_values_json(&values_dir);
1231
1232 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1233 }
1234
1235 mod watcher_tests {
1236 use super::*;
1237 use std::thread;
1238
1239 fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1241 let temp_dir = TempDir::new().unwrap();
1242 let schemas_dir = temp_dir.path().join("schemas");
1243 let values_dir = temp_dir.path().join("values");
1244
1245 let ns1_schema = schemas_dir.join("ns1");
1246 fs::create_dir_all(&ns1_schema).unwrap();
1247 fs::write(
1248 ns1_schema.join("schema.json"),
1249 r#"{
1250 "version": "1.0",
1251 "type": "object",
1252 "properties": {
1253 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1254 }
1255 }"#,
1256 )
1257 .unwrap();
1258
1259 let ns1_values = values_dir.join("ns1");
1260 fs::create_dir_all(&ns1_values).unwrap();
1261 fs::write(
1262 ns1_values.join("values.json"),
1263 r#"{"options": {"enabled": true}}"#,
1264 )
1265 .unwrap();
1266
1267 let ns2_schema = schemas_dir.join("ns2");
1268 fs::create_dir_all(&ns2_schema).unwrap();
1269 fs::write(
1270 ns2_schema.join("schema.json"),
1271 r#"{
1272 "version": "1.0",
1273 "type": "object",
1274 "properties": {
1275 "count": {"type": "integer", "default": 0, "description": "Count"}
1276 }
1277 }"#,
1278 )
1279 .unwrap();
1280
1281 let ns2_values = values_dir.join("ns2");
1282 fs::create_dir_all(&ns2_values).unwrap();
1283 fs::write(
1284 ns2_values.join("values.json"),
1285 r#"{"options": {"count": 42}}"#,
1286 )
1287 .unwrap();
1288
1289 (temp_dir, schemas_dir, values_dir)
1290 }
1291
1292 #[test]
1293 fn test_get_mtime_returns_most_recent() {
1294 let (_temp, _schemas, values_dir) = setup_watcher_test();
1295
1296 let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1298 assert!(mtime1.is_some());
1299
1300 thread::sleep(std::time::Duration::from_millis(10));
1302 fs::write(
1303 values_dir.join("ns1").join("values.json"),
1304 r#"{"options": {"enabled": false}}"#,
1305 )
1306 .unwrap();
1307
1308 let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1310 assert!(mtime2.is_some());
1311 assert!(mtime2 > mtime1);
1312 }
1313
1314 #[test]
1315 fn test_get_mtime_with_missing_directory() {
1316 let temp = TempDir::new().unwrap();
1317 let nonexistent = temp.path().join("nonexistent");
1318
1319 let mtime = ValuesWatcher::get_mtime(&nonexistent);
1320 assert!(mtime.is_none());
1321 }
1322
1323 #[test]
1324 fn test_reload_values_updates_map() {
1325 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1326
1327 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1328 let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1329 let values = Arc::new(RwLock::new(initial_values));
1330
1331 {
1333 let guard = values.read().unwrap();
1334 assert_eq!(guard["ns1"]["enabled"], json!(true));
1335 assert_eq!(guard["ns2"]["count"], json!(42));
1336 }
1337
1338 fs::write(
1340 values_dir.join("ns1").join("values.json"),
1341 r#"{"options": {"enabled": false}}"#,
1342 )
1343 .unwrap();
1344 fs::write(
1345 values_dir.join("ns2").join("values.json"),
1346 r#"{"options": {"count": 100}}"#,
1347 )
1348 .unwrap();
1349
1350 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1352
1353 {
1355 let guard = values.read().unwrap();
1356 assert_eq!(guard["ns1"]["enabled"], json!(false));
1357 assert_eq!(guard["ns2"]["count"], json!(100));
1358 }
1359 }
1360
1361 #[test]
1362 fn test_old_values_persist_with_invalid_data() {
1363 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1364
1365 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1366 let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1367 let values = Arc::new(RwLock::new(initial_values));
1368
1369 let initial_enabled = {
1370 let guard = values.read().unwrap();
1371 guard["ns1"]["enabled"].clone()
1372 };
1373
1374 fs::write(
1376 values_dir.join("ns1").join("values.json"),
1377 r#"{"options": {"enabled": "not-a-boolean"}}"#,
1378 )
1379 .unwrap();
1380
1381 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1382
1383 {
1385 let guard = values.read().unwrap();
1386 assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1387 }
1388 }
1389
1390 #[test]
1391 fn test_watcher_creation_and_termination() {
1392 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1393
1394 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1395 let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1396 let values = Arc::new(RwLock::new(initial_values));
1397
1398 let mut watcher =
1399 ValuesWatcher::new(&values_dir, Arc::clone(®istry), Arc::clone(&values))
1400 .expect("Failed to create watcher");
1401
1402 assert!(watcher.is_alive());
1403 watcher.stop();
1404 assert!(!watcher.is_alive());
1405 }
1406 }
1407}