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 property_schema: Value,
171 pub default: Value,
172}
173
174pub struct NamespaceSchema {
176 pub namespace: String,
177 pub options: HashMap<String, OptionMetadata>,
178 validator: jsonschema::Validator,
179}
180
181impl NamespaceSchema {
182 pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
190 let output = self.validator.evaluate(values);
191 if output.flag().valid {
192 Ok(())
193 } else {
194 let errors: Vec<String> = output
195 .iter_errors()
196 .map(|e| {
197 format!(
198 "\n\t{} {}",
199 e.instance_location.as_str().trim_start_matches("/"),
200 e.error
201 )
202 })
203 .collect();
204 Err(ValidationError::ValueError {
205 namespace: self.namespace.clone(),
206 errors: errors.join(""),
207 })
208 }
209 }
210
211 pub fn get_default(&self, key: &str) -> Option<&Value> {
214 self.options.get(key).map(|meta| &meta.default)
215 }
216}
217
218pub struct SchemaRegistry {
220 schemas: HashMap<String, Arc<NamespaceSchema>>,
221}
222
223impl SchemaRegistry {
224 pub fn new() -> Self {
226 Self {
227 schemas: HashMap::new(),
228 }
229 }
230
231 pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
241 let schemas = Self::load_all_schemas(schemas_dir)?;
242 Ok(Self { schemas })
243 }
244
245 pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
254 let schema = self
255 .schemas
256 .get(namespace)
257 .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
258
259 schema.validate_values(values)
260 }
261
262 fn load_all_schemas(
264 schemas_dir: &Path,
265 ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
266 let namespace_schema_value: Value =
268 serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
269 ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
270 })?;
271 let namespace_validator =
272 jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
273 ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
274 })?;
275
276 let mut schemas = HashMap::new();
277
278 for entry in fs::read_dir(schemas_dir)? {
280 let entry = entry?;
281
282 if !entry.file_type()?.is_dir() {
283 continue;
284 }
285
286 let namespace =
287 entry
288 .file_name()
289 .into_string()
290 .map_err(|_| ValidationError::SchemaError {
291 file: entry.path(),
292 message: "Directory name contains invalid UTF-8".to_string(),
293 })?;
294
295 validate_k8s_name_component(&namespace, "namespace name")?;
296
297 let schema_file = entry.path().join(SCHEMA_FILE_NAME);
298 let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
299 schemas.insert(namespace, schema);
300 }
301
302 Ok(schemas)
303 }
304
305 fn load_schema(
307 path: &Path,
308 namespace: &str,
309 namespace_validator: &jsonschema::Validator,
310 ) -> ValidationResult<Arc<NamespaceSchema>> {
311 let file = fs::File::open(path)?;
312 let schema_data: Value = serde_json::from_reader(file)?;
313
314 Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
315 Self::parse_schema(schema_data, namespace, path)
316 }
317
318 fn validate_with_namespace_schema(
320 schema_data: &Value,
321 path: &Path,
322 namespace_validator: &jsonschema::Validator,
323 ) -> ValidationResult<()> {
324 let output = namespace_validator.evaluate(schema_data);
325
326 if output.flag().valid {
327 Ok(())
328 } else {
329 let errors: Vec<String> = output
330 .iter_errors()
331 .map(|e| format!("Error: {}", e.error))
332 .collect();
333
334 Err(ValidationError::SchemaError {
335 file: path.to_path_buf(),
336 message: format!("Schema validation failed:\n{}", errors.join("\n")),
337 })
338 }
339 }
340
341 fn validate_default_type(
343 property_name: &str,
344 property_schema: &Value,
345 default_value: &Value,
346 path: &Path,
347 ) -> ValidationResult<()> {
348 jsonschema::validate(property_schema, default_value).map_err(|e| {
350 ValidationError::SchemaError {
351 file: path.to_path_buf(),
352 message: format!(
353 "Property '{}': default value does not match schema: {}",
354 property_name, e
355 ),
356 }
357 })?;
358
359 Ok(())
360 }
361
362 fn parse_schema(
364 mut schema: Value,
365 namespace: &str,
366 path: &Path,
367 ) -> ValidationResult<Arc<NamespaceSchema>> {
368 if let Some(obj) = schema.as_object_mut() {
370 obj.insert("additionalProperties".to_string(), json!(false));
371 }
372
373 let validator =
375 jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
376 file: path.to_path_buf(),
377 message: format!("Failed to compile validator: {}", e),
378 })?;
379
380 let mut options = HashMap::new();
382 if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
383 for (prop_name, prop_value) in properties {
384 if let (Some(prop_type), Some(default_value)) = (
385 prop_value.get("type").and_then(|t| t.as_str()),
386 prop_value.get("default"),
387 ) {
388 Self::validate_default_type(prop_name, prop_value, default_value, path)?;
389 options.insert(
390 prop_name.clone(),
391 OptionMetadata {
392 option_type: prop_type.to_string(),
393 property_schema: prop_value.clone(),
394 default: default_value.clone(),
395 },
396 );
397 }
398 }
399 }
400
401 Ok(Arc::new(NamespaceSchema {
402 namespace: namespace.to_string(),
403 options,
404 validator,
405 }))
406 }
407
408 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
410 self.schemas.get(namespace)
411 }
412
413 pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
415 &self.schemas
416 }
417
418 pub fn load_values_json(
424 &self,
425 values_dir: &Path,
426 ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
427 let mut all_values = HashMap::new();
428 let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
429
430 for namespace in self.schemas.keys() {
431 let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
432
433 if !values_file.exists() {
434 continue;
435 }
436
437 let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
438
439 if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
441 generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
442 }
443
444 let values = parsed
445 .get("options")
446 .ok_or_else(|| ValidationError::ValueError {
447 namespace: namespace.clone(),
448 errors: "values.json must have an 'options' key".to_string(),
449 })?;
450
451 self.validate_values(namespace, values)?;
452
453 if let Value::Object(obj) = values.clone() {
454 let ns_values: HashMap<String, Value> = obj.into_iter().collect();
455 all_values.insert(namespace.clone(), ns_values);
456 }
457 }
458
459 Ok((all_values, generated_at_by_namespace))
460 }
461}
462
463impl Default for SchemaRegistry {
464 fn default() -> Self {
465 Self::new()
466 }
467}
468
469pub struct ValuesWatcher {
484 stop_signal: Arc<AtomicBool>,
485 thread: Option<JoinHandle<()>>,
486}
487
488impl ValuesWatcher {
489 pub fn new(
491 values_path: &Path,
492 registry: Arc<SchemaRegistry>,
493 values: Arc<RwLock<ValuesByNamespace>>,
494 ) -> ValidationResult<Self> {
495 if !should_suppress_missing_dir_errors() && fs::metadata(values_path).is_err() {
497 eprintln!("Values directory does not exist: {}", values_path.display());
498 }
499
500 let stop_signal = Arc::new(AtomicBool::new(false));
501
502 let thread_signal = Arc::clone(&stop_signal);
503 let thread_path = values_path.to_path_buf();
504 let thread_registry = Arc::clone(®istry);
505 let thread_values = Arc::clone(&values);
506 let thread = thread::Builder::new()
507 .name("sentry-options-watcher".into())
508 .spawn(move || {
509 let result = panic::catch_unwind(AssertUnwindSafe(|| {
510 Self::run(thread_signal, thread_path, thread_registry, thread_values);
511 }));
512 if let Err(e) = result {
513 eprintln!("Watcher thread panicked with: {:?}", e);
514 }
515 })?;
516
517 Ok(Self {
518 stop_signal,
519 thread: Some(thread),
520 })
521 }
522
523 fn run(
528 stop_signal: Arc<AtomicBool>,
529 values_path: PathBuf,
530 registry: Arc<SchemaRegistry>,
531 values: Arc<RwLock<ValuesByNamespace>>,
532 ) {
533 let mut last_mtime = Self::get_mtime(&values_path);
534
535 while !stop_signal.load(Ordering::Relaxed) {
536 if let Some(current_mtime) = Self::get_mtime(&values_path)
538 && Some(current_mtime) != last_mtime
539 {
540 Self::reload_values(&values_path, ®istry, &values);
541 last_mtime = Some(current_mtime);
542 }
543
544 thread::sleep(Duration::from_secs(POLLING_DELAY));
545 }
546 }
547
548 fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
551 let mut latest_mtime = None;
552
553 let entries = match fs::read_dir(values_dir) {
554 Ok(e) => e,
555 Err(e) => {
556 if !should_suppress_missing_dir_errors() {
557 eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
558 }
559 return None;
560 }
561 };
562
563 for entry in entries.flatten() {
564 if !entry
566 .file_type()
567 .map(|file_type| file_type.is_dir())
568 .unwrap_or(false)
569 {
570 continue;
571 }
572
573 let values_file = entry.path().join(VALUES_FILE_NAME);
574 if let Ok(metadata) = fs::metadata(&values_file)
575 && let Ok(mtime) = metadata.modified()
576 && latest_mtime.is_none_or(|latest| mtime > latest)
577 {
578 latest_mtime = Some(mtime);
579 }
580 }
581
582 latest_mtime
583 }
584
585 fn reload_values(
588 values_path: &Path,
589 registry: &SchemaRegistry,
590 values: &Arc<RwLock<ValuesByNamespace>>,
591 ) {
592 let reload_start = Instant::now();
593
594 match registry.load_values_json(values_path) {
595 Ok((new_values, generated_at_by_namespace)) => {
596 let namespaces: Vec<String> = new_values.keys().cloned().collect();
597 Self::update_values(values, new_values);
598
599 let reload_duration = reload_start.elapsed();
600 Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
601 }
602 Err(e) => {
603 eprintln!(
604 "Failed to reload values from {}: {}",
605 values_path.display(),
606 e
607 );
608 }
609 }
610 }
611
612 fn emit_reload_spans(
615 namespaces: &[String],
616 reload_duration: Duration,
617 generated_at_by_namespace: &HashMap<String, String>,
618 ) {
619 let hub = get_sentry_hub();
620 let applied_at = Utc::now();
621 let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
622
623 for namespace in namespaces {
624 let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
625 tx_ctx.set_sampled(true);
626
627 let transaction = hub.start_transaction(tx_ctx);
628 transaction.set_data("reload_duration_ms", reload_duration_ms.into());
629 transaction.set_data("applied_at", applied_at.to_rfc3339().into());
630
631 if let Some(ts) = generated_at_by_namespace.get(namespace) {
632 transaction.set_data("generated_at", ts.as_str().into());
633
634 if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
635 let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
636 .num_milliseconds() as f64
637 / 1000.0;
638 transaction.set_data("propagation_delay_secs", delay_secs.into());
639 }
640 }
641
642 transaction.finish();
643 }
644 }
645
646 fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
648 let mut guard = values.write().unwrap();
650 *guard = new_values;
651 }
652
653 pub fn stop(&mut self) {
656 self.stop_signal.store(true, Ordering::Relaxed);
657 if let Some(thread) = self.thread.take() {
658 let _ = thread.join();
659 }
660 }
661
662 pub fn is_alive(&self) -> bool {
664 self.thread.as_ref().is_some_and(|t| !t.is_finished())
665 }
666}
667
668impl Drop for ValuesWatcher {
669 fn drop(&mut self) {
670 self.stop();
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use tempfile::TempDir;
678
679 fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
680 let schema_dir = temp_dir.path().join(namespace);
681 fs::create_dir_all(&schema_dir).unwrap();
682 let schema_file = schema_dir.join("schema.json");
683 fs::write(&schema_file, schema_json).unwrap();
684 schema_file
685 }
686
687 #[test]
688 fn test_validate_k8s_name_component_valid() {
689 assert!(validate_k8s_name_component("relay", "namespace").is_ok());
690 assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
691 assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
692 assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
693 }
694
695 #[test]
696 fn test_validate_k8s_name_component_rejects_uppercase() {
697 let result = validate_k8s_name_component("MyService", "namespace");
698 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
699 assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
700 }
701
702 #[test]
703 fn test_validate_k8s_name_component_rejects_underscore() {
704 let result = validate_k8s_name_component("my_service", "target");
705 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
706 assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
707 }
708
709 #[test]
710 fn test_validate_k8s_name_component_rejects_leading_hyphen() {
711 let result = validate_k8s_name_component("-service", "namespace");
712 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
713 assert!(
714 result
715 .unwrap_err()
716 .to_string()
717 .contains("start and end with alphanumeric")
718 );
719 }
720
721 #[test]
722 fn test_validate_k8s_name_component_rejects_trailing_dot() {
723 let result = validate_k8s_name_component("service.", "namespace");
724 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
725 assert!(
726 result
727 .unwrap_err()
728 .to_string()
729 .contains("start and end with alphanumeric")
730 );
731 }
732
733 #[test]
734 fn test_load_schema_valid() {
735 let temp_dir = TempDir::new().unwrap();
736 create_test_schema(
737 &temp_dir,
738 "test",
739 r#"{
740 "version": "1.0",
741 "type": "object",
742 "properties": {
743 "test-key": {
744 "type": "string",
745 "default": "test",
746 "description": "Test option"
747 }
748 }
749 }"#,
750 );
751
752 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
753 }
754
755 #[test]
756 fn test_load_schema_missing_version() {
757 let temp_dir = TempDir::new().unwrap();
758 create_test_schema(
759 &temp_dir,
760 "test",
761 r#"{
762 "type": "object",
763 "properties": {}
764 }"#,
765 );
766
767 let result = SchemaRegistry::from_directory(temp_dir.path());
768 assert!(result.is_err());
769 match result {
770 Err(ValidationError::SchemaError { message, .. }) => {
771 assert!(message.contains(
772 "Schema validation failed:
773Error: \"version\" is a required property"
774 ));
775 }
776 _ => panic!("Expected SchemaError for missing version"),
777 }
778 }
779
780 #[test]
781 fn test_unknown_namespace() {
782 let temp_dir = TempDir::new().unwrap();
783 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
784
785 let result = registry.validate_values("unknown", &json!({}));
786 assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
787 }
788
789 #[test]
790 fn test_multiple_namespaces() {
791 let temp_dir = TempDir::new().unwrap();
792 create_test_schema(
793 &temp_dir,
794 "ns1",
795 r#"{
796 "version": "1.0",
797 "type": "object",
798 "properties": {
799 "opt1": {
800 "type": "string",
801 "default": "default1",
802 "description": "First option"
803 }
804 }
805 }"#,
806 );
807 create_test_schema(
808 &temp_dir,
809 "ns2",
810 r#"{
811 "version": "2.0",
812 "type": "object",
813 "properties": {
814 "opt2": {
815 "type": "integer",
816 "default": 42,
817 "description": "Second option"
818 }
819 }
820 }"#,
821 );
822
823 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
824 assert!(registry.schemas.contains_key("ns1"));
825 assert!(registry.schemas.contains_key("ns2"));
826 }
827
828 #[test]
829 fn test_invalid_default_type() {
830 let temp_dir = TempDir::new().unwrap();
831 create_test_schema(
832 &temp_dir,
833 "test",
834 r#"{
835 "version": "1.0",
836 "type": "object",
837 "properties": {
838 "bad-default": {
839 "type": "integer",
840 "default": "not-a-number",
841 "description": "A bad default value"
842 }
843 }
844 }"#,
845 );
846
847 let result = SchemaRegistry::from_directory(temp_dir.path());
848 assert!(result.is_err());
849 match result {
850 Err(ValidationError::SchemaError { message, .. }) => {
851 assert!(
852 message.contains("Property 'bad-default': default value does not match schema")
853 );
854 assert!(message.contains("\"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 mod array_tests {
1435 use super::*;
1436
1437 fn create_test_schema_with_values(
1439 temp_dir: &TempDir,
1440 namespace: &str,
1441 schema_json: &str,
1442 values_json: &str,
1443 ) -> (PathBuf, PathBuf) {
1444 let schemas_dir = temp_dir.path().join("schemas");
1445 let values_dir = temp_dir.path().join("values");
1446
1447 let schema_dir = schemas_dir.join(namespace);
1448 fs::create_dir_all(&schema_dir).unwrap();
1449 let schema_file = schema_dir.join("schema.json");
1450 fs::write(&schema_file, schema_json).unwrap();
1451
1452 let ns_values_dir = values_dir.join(namespace);
1453 fs::create_dir_all(&ns_values_dir).unwrap();
1454 let values_file = ns_values_dir.join("values.json");
1455 fs::write(&values_file, values_json).unwrap();
1456
1457 (schemas_dir, values_dir)
1458 }
1459
1460 #[test]
1461 fn test_basic_schema_validation() {
1462 let temp_dir = TempDir::new().unwrap();
1463 for (a_type, default) in [
1464 ("boolean", ""), ("boolean", "true"),
1466 ("integer", "1"),
1467 ("number", "1.2"),
1468 ("string", "\"wow\""),
1469 ] {
1470 create_test_schema(
1471 &temp_dir,
1472 "test",
1473 &format!(
1474 r#"{{
1475 "version": "1.0",
1476 "type": "object",
1477 "properties": {{
1478 "array-key": {{
1479 "type": "array",
1480 "items": {{"type": "{}"}},
1481 "default": [{}],
1482 "description": "Array option"
1483 }}
1484 }}
1485 }}"#,
1486 a_type, default
1487 ),
1488 );
1489
1490 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1491 }
1492 }
1493
1494 #[test]
1495 fn test_missing_items_object_rejection() {
1496 let temp_dir = TempDir::new().unwrap();
1497 create_test_schema(
1498 &temp_dir,
1499 "test",
1500 r#"{
1501 "version": "1.0",
1502 "type": "object",
1503 "properties": {
1504 "array-key": {
1505 "type": "array",
1506 "default": [1,2,3],
1507 "description": "Array option"
1508 }
1509 }
1510 }"#,
1511 );
1512
1513 let result = SchemaRegistry::from_directory(temp_dir.path());
1514 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1515 }
1516
1517 #[test]
1518 fn test_malformed_items_rejection() {
1519 let temp_dir = TempDir::new().unwrap();
1520 create_test_schema(
1521 &temp_dir,
1522 "test",
1523 r#"{
1524 "version": "1.0",
1525 "type": "object",
1526 "properties": {
1527 "array-key": {
1528 "type": "array",
1529 "items": {"type": ""},
1530 "default": [1,2,3],
1531 "description": "Array option"
1532 }
1533 }
1534 }"#,
1535 );
1536
1537 let result = SchemaRegistry::from_directory(temp_dir.path());
1538 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1539 }
1540
1541 #[test]
1542 fn test_schema_default_type_mismatch_rejection() {
1543 let temp_dir = TempDir::new().unwrap();
1544 create_test_schema(
1546 &temp_dir,
1547 "test",
1548 r#"{
1549 "version": "1.0",
1550 "type": "object",
1551 "properties": {
1552 "array-key": {
1553 "type": "array",
1554 "items": {"type": "integer"},
1555 "default": [1,2,3.3],
1556 "description": "Array option"
1557 }
1558 }
1559 }"#,
1560 );
1561
1562 let result = SchemaRegistry::from_directory(temp_dir.path());
1563 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1564 }
1565
1566 #[test]
1567 fn test_schema_default_heterogeneous_rejection() {
1568 let temp_dir = TempDir::new().unwrap();
1569 create_test_schema(
1570 &temp_dir,
1571 "test",
1572 r#"{
1573 "version": "1.0",
1574 "type": "object",
1575 "properties": {
1576 "array-key": {
1577 "type": "array",
1578 "items": {"type": "integer"},
1579 "default": [1,2,"uh oh!"],
1580 "description": "Array option"
1581 }
1582 }
1583 }"#,
1584 );
1585
1586 let result = SchemaRegistry::from_directory(temp_dir.path());
1587 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1588 }
1589
1590 #[test]
1591 fn test_load_values_valid() {
1592 let temp_dir = TempDir::new().unwrap();
1593 let (schemas_dir, values_dir) = create_test_schema_with_values(
1594 &temp_dir,
1595 "test",
1596 r#"{
1597 "version": "1.0",
1598 "type": "object",
1599 "properties": {
1600 "array-key": {
1601 "type": "array",
1602 "items": {"type": "integer"},
1603 "default": [1,2,3],
1604 "description": "Array option"
1605 }
1606 }
1607 }"#,
1608 r#"{
1609 "options": {
1610 "array-key": [4,5,6]
1611 }
1612 }"#,
1613 );
1614
1615 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1616 let (values, generated_at_by_namespace) =
1617 registry.load_values_json(&values_dir).unwrap();
1618
1619 assert_eq!(values.len(), 1);
1620 assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
1621 assert!(generated_at_by_namespace.is_empty());
1622 }
1623
1624 #[test]
1625 fn test_reject_values_not_an_array() {
1626 let temp_dir = TempDir::new().unwrap();
1627 let (schemas_dir, values_dir) = create_test_schema_with_values(
1628 &temp_dir,
1629 "test",
1630 r#"{
1631 "version": "1.0",
1632 "type": "object",
1633 "properties": {
1634 "array-key": {
1635 "type": "array",
1636 "items": {"type": "integer"},
1637 "default": [1,2,3],
1638 "description": "Array option"
1639 }
1640 }
1641 }"#,
1642 r#"{
1644 "options": {
1645 "array-key": "[]"
1646 }
1647 }"#,
1648 );
1649
1650 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1651 let result = registry.load_values_json(&values_dir);
1652
1653 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1654 }
1655
1656 #[test]
1657 fn test_reject_values_mismatch() {
1658 let temp_dir = TempDir::new().unwrap();
1659 let (schemas_dir, values_dir) = create_test_schema_with_values(
1660 &temp_dir,
1661 "test",
1662 r#"{
1663 "version": "1.0",
1664 "type": "object",
1665 "properties": {
1666 "array-key": {
1667 "type": "array",
1668 "items": {"type": "integer"},
1669 "default": [1,2,3],
1670 "description": "Array option"
1671 }
1672 }
1673 }"#,
1674 r#"{
1675 "options": {
1676 "array-key": ["a","b","c"]
1677 }
1678 }"#,
1679 );
1680
1681 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1682 let result = registry.load_values_json(&values_dir);
1683
1684 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1685 }
1686 }
1687}