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