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