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