Skip to main content

sentry_options_validation/
lib.rs

1//! Schema validation library for sentry-options
2//!
3//! This library provides schema loading and validation for sentry-options.
4//! Schemas are loaded once and stored in Arc for efficient sharing.
5//! Values are validated against schemas as complete objects.
6
7use 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
24/// Embedded meta-schema for validating sentry-options schema files
25const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
26const SCHEMA_FILE_NAME: &str = "schema.json";
27const VALUES_FILE_NAME: &str = "values.json";
28
29/// Time between file polls in seconds
30const POLLING_DELAY: u64 = 5;
31
32/// Dedicated Sentry DSN for sentry-options observability.
33/// This is separate from the host application's Sentry setup.
34#[cfg(not(test))]
35const SENTRY_OPTIONS_DSN: &str =
36    "https://d3598a07e9f23a9acee9e2718cfd17bd@o1.ingest.us.sentry.io/4510750163927040";
37
38/// Disabled DSN for tests - empty string creates a disabled client
39#[cfg(test)]
40const SENTRY_OPTIONS_DSN: &str = "";
41
42/// Lazily-initialized dedicated Sentry Hub for sentry-options.
43/// Uses a custom Client that is completely isolated from the host application's Sentry setup.
44/// In test mode, creates a disabled client (empty DSN) so no spans are sent.
45static 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                // Explicitly set transport factory - required when not using sentry::init()
54                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
65/// Production path where options are deployed via config map
66pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
67
68/// Local fallback path for development
69pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
70
71/// Environment variable to override options directory
72pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
73
74/// Resolve options directory using fallback chain:
75/// 1. `SENTRY_OPTIONS_DIR` env var (if set)
76/// 2. `/etc/sentry-options` (if exists)
77/// 3. `sentry-options/` (local fallback)
78pub 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
91/// Result type for validation operations
92pub type ValidationResult<T> = Result<T, ValidationError>;
93
94/// A map of option values keyed by their namespace
95pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
96
97/// Errors that can occur during schema and value validation
98#[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
129/// Validate a name component is valid for K8s (lowercase alphanumeric, '-', '.')
130pub 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/// Metadata for a single option in a namespace schema
157#[derive(Debug, Clone)]
158pub struct OptionMetadata {
159    pub option_type: String,
160    pub default: Value,
161}
162
163/// Schema for a namespace, containing validator and option metadata
164pub struct NamespaceSchema {
165    pub namespace: String,
166    pub options: HashMap<String, OptionMetadata>,
167    validator: jsonschema::Validator,
168}
169
170impl NamespaceSchema {
171    /// Validate an entire values object against this schema
172    ///
173    /// # Arguments
174    /// * `values` - JSON object containing option key-value pairs
175    ///
176    /// # Errors
177    /// Returns error if values don't match the schema
178    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
179        let output = self.validator.evaluate(values);
180        if output.flag().valid {
181            Ok(())
182        } else {
183            let errors: Vec<String> = output
184                .iter_errors()
185                .map(|e| {
186                    format!(
187                        "\n\t{} {}",
188                        e.instance_location.as_str().trim_start_matches("/"),
189                        e.error
190                    )
191                })
192                .collect();
193            Err(ValidationError::ValueError {
194                namespace: self.namespace.clone(),
195                errors: errors.join(""),
196            })
197        }
198    }
199
200    /// Get the default value for an option key.
201    /// Returns None if the key doesn't exist in the schema.
202    pub fn get_default(&self, key: &str) -> Option<&Value> {
203        self.options.get(key).map(|meta| &meta.default)
204    }
205}
206
207/// Registry for loading and storing schemas
208pub struct SchemaRegistry {
209    schemas: HashMap<String, Arc<NamespaceSchema>>,
210}
211
212impl SchemaRegistry {
213    /// Create a new empty schema registry
214    pub fn new() -> Self {
215        Self {
216            schemas: HashMap::new(),
217        }
218    }
219
220    /// Load schemas from a directory and create a registry
221    ///
222    /// Expects directory structure: `schemas/{namespace}/schema.json`
223    ///
224    /// # Arguments
225    /// * `schemas_dir` - Path to directory containing namespace subdirectories
226    ///
227    /// # Errors
228    /// Returns error if directory doesn't exist or any schema is invalid
229    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
230        let schemas = Self::load_all_schemas(schemas_dir)?;
231        Ok(Self { schemas })
232    }
233
234    /// Validate an entire values object for a namespace
235    ///
236    /// # Arguments
237    /// * `namespace` - Namespace name
238    /// * `values` - JSON object containing option key-value pairs
239    ///
240    /// # Errors
241    /// Returns error if namespace doesn't exist or values don't match schema
242    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
243        let schema = self
244            .schemas
245            .get(namespace)
246            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
247
248        schema.validate_values(values)
249    }
250
251    /// Load all schemas from a directory
252    fn load_all_schemas(
253        schemas_dir: &Path,
254    ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
255        // Compile namespace-schema once for all schemas
256        let namespace_schema_value: Value =
257            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
258                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
259            })?;
260        let namespace_validator =
261            jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
262                ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
263            })?;
264
265        let mut schemas = HashMap::new();
266
267        // TODO: Parallelize the loading of schemas for the performance gainz
268        for entry in fs::read_dir(schemas_dir)? {
269            let entry = entry?;
270
271            if !entry.file_type()?.is_dir() {
272                continue;
273            }
274
275            let namespace =
276                entry
277                    .file_name()
278                    .into_string()
279                    .map_err(|_| ValidationError::SchemaError {
280                        file: entry.path(),
281                        message: "Directory name contains invalid UTF-8".to_string(),
282                    })?;
283
284            validate_k8s_name_component(&namespace, "namespace name")?;
285
286            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
287            let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
288            schemas.insert(namespace, schema);
289        }
290
291        Ok(schemas)
292    }
293
294    /// Load a schema from a file
295    fn load_schema(
296        path: &Path,
297        namespace: &str,
298        namespace_validator: &jsonschema::Validator,
299    ) -> ValidationResult<Arc<NamespaceSchema>> {
300        let file = fs::File::open(path)?;
301        let schema_data: Value = serde_json::from_reader(file)?;
302
303        Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
304        Self::parse_schema(schema_data, namespace, path)
305    }
306
307    /// Validate a schema against the namespace-schema
308    fn validate_with_namespace_schema(
309        schema_data: &Value,
310        path: &Path,
311        namespace_validator: &jsonschema::Validator,
312    ) -> ValidationResult<()> {
313        let output = namespace_validator.evaluate(schema_data);
314
315        if output.flag().valid {
316            Ok(())
317        } else {
318            let errors: Vec<String> = output
319                .iter_errors()
320                .map(|e| format!("Error: {}", e.error))
321                .collect();
322
323            Err(ValidationError::SchemaError {
324                file: path.to_path_buf(),
325                message: format!("Schema validation failed:\n{}", errors.join("\n")),
326            })
327        }
328    }
329
330    /// Validate that a default value matches its declared type using jsonschema
331    fn validate_default_type(
332        property_name: &str,
333        property_type: &str,
334        default_value: &Value,
335        path: &Path,
336    ) -> ValidationResult<()> {
337        // Build a mini JSON Schema for just this type
338        let type_schema = serde_json::json!({
339            "type": property_type
340        });
341
342        // Validate the default value against the type
343        jsonschema::validate(&type_schema, default_value).map_err(|e| {
344            ValidationError::SchemaError {
345                file: path.to_path_buf(),
346                message: format!(
347                    "Property '{}': default value does not match type '{}': {}",
348                    property_name, property_type, e
349                ),
350            }
351        })?;
352
353        Ok(())
354    }
355
356    /// Parse a schema JSON into NamespaceSchema
357    fn parse_schema(
358        mut schema: Value,
359        namespace: &str,
360        path: &Path,
361    ) -> ValidationResult<Arc<NamespaceSchema>> {
362        // Inject additionalProperties: false to reject unknown options
363        if let Some(obj) = schema.as_object_mut() {
364            obj.insert("additionalProperties".to_string(), json!(false));
365        }
366
367        // Use the schema file directly as the validator
368        let validator =
369            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
370                file: path.to_path_buf(),
371                message: format!("Failed to compile validator: {}", e),
372            })?;
373
374        // Extract option metadata and validate types
375        let mut options = HashMap::new();
376        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
377            for (prop_name, prop_value) in properties {
378                if let (Some(prop_type), Some(default_value)) = (
379                    prop_value.get("type").and_then(|t| t.as_str()),
380                    prop_value.get("default"),
381                ) {
382                    Self::validate_default_type(prop_name, prop_type, default_value, path)?;
383                    options.insert(
384                        prop_name.clone(),
385                        OptionMetadata {
386                            option_type: prop_type.to_string(),
387                            default: default_value.clone(),
388                        },
389                    );
390                }
391            }
392        }
393
394        Ok(Arc::new(NamespaceSchema {
395            namespace: namespace.to_string(),
396            options,
397            validator,
398        }))
399    }
400
401    /// Get a namespace schema by name
402    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
403        self.schemas.get(namespace)
404    }
405
406    /// Get all loaded schemas (for schema evolution validation)
407    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
408        &self.schemas
409    }
410
411    /// Load and validate JSON values from a directory.
412    /// Expects structure: `{values_dir}/{namespace}/values.json`
413    /// Values file must have format: `{"options": {"key": value, ...}, "generated_at": "..."}`
414    /// Skips namespaces without a values.json file.
415    /// Returns the values and a map of namespace -> `generated_at` timestamp.
416    pub fn load_values_json(
417        &self,
418        values_dir: &Path,
419    ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
420        let mut all_values = HashMap::new();
421        let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
422
423        for namespace in self.schemas.keys() {
424            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
425
426            if !values_file.exists() {
427                continue;
428            }
429
430            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
431
432            // Extract generated_at if present
433            if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
434                generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
435            }
436
437            let values = parsed
438                .get("options")
439                .ok_or_else(|| ValidationError::ValueError {
440                    namespace: namespace.clone(),
441                    errors: "values.json must have an 'options' key".to_string(),
442                })?;
443
444            self.validate_values(namespace, values)?;
445
446            if let Value::Object(obj) = values.clone() {
447                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
448                all_values.insert(namespace.clone(), ns_values);
449            }
450        }
451
452        Ok((all_values, generated_at_by_namespace))
453    }
454}
455
456impl Default for SchemaRegistry {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462/// Watches the values directory for changes, reloading if there are any.
463/// If the directory does not exist we do not panic
464///
465/// Does not do an initial fetch, assumes the caller has already loaded values.
466/// Child thread may panic if we run out of memory or cannot create more threads.
467///
468/// Uses polling for now, could use `inotify` or similar later on.
469///
470/// Some important notes:
471/// - If the thread panics and dies, there is no built in mechanism to catch it and restart
472/// - If a config map is unmounted, we won't reload until the next file modification (because we don't catch the deletion event)
473/// - If any namespace fails validation, we keep all old values (even the namespaces that passed validation)
474/// - If we have a steady stream of readers our writer may starve for a while trying to acquire the lock
475/// - stop() will block until the thread gets joined
476pub struct ValuesWatcher {
477    stop_signal: Arc<AtomicBool>,
478    thread: Option<JoinHandle<()>>,
479}
480
481impl ValuesWatcher {
482    /// Creates a new ValuesWatcher struct and spins up the watcher thread
483    pub fn new(
484        values_path: &Path,
485        registry: Arc<SchemaRegistry>,
486        values: Arc<RwLock<ValuesByNamespace>>,
487    ) -> ValidationResult<Self> {
488        // output an error but keep passing
489        if fs::metadata(values_path).is_err() {
490            eprintln!("Values directory does not exist: {}", values_path.display());
491        }
492
493        let stop_signal = Arc::new(AtomicBool::new(false));
494
495        let thread_signal = Arc::clone(&stop_signal);
496        let thread_path = values_path.to_path_buf();
497        let thread_registry = Arc::clone(&registry);
498        let thread_values = Arc::clone(&values);
499        let thread = thread::Builder::new()
500            .name("sentry-options-watcher".into())
501            .spawn(move || {
502                let result = panic::catch_unwind(AssertUnwindSafe(|| {
503                    Self::run(thread_signal, thread_path, thread_registry, thread_values);
504                }));
505                if let Err(e) = result {
506                    eprintln!("Watcher thread panicked with: {:?}", e);
507                }
508            })?;
509
510        Ok(Self {
511            stop_signal,
512            thread: Some(thread),
513        })
514    }
515
516    /// Reloads the values if the modified time has changed.
517    ///
518    /// Continuously polls the values directory and reloads all values
519    /// if any modification is detected.
520    fn run(
521        stop_signal: Arc<AtomicBool>,
522        values_path: PathBuf,
523        registry: Arc<SchemaRegistry>,
524        values: Arc<RwLock<ValuesByNamespace>>,
525    ) {
526        let mut last_mtime = Self::get_mtime(&values_path);
527
528        while !stop_signal.load(Ordering::Relaxed) {
529            // does not reload values if get_mtime fails
530            if let Some(current_mtime) = Self::get_mtime(&values_path)
531                && Some(current_mtime) != last_mtime
532            {
533                Self::reload_values(&values_path, &registry, &values);
534                last_mtime = Some(current_mtime);
535            }
536
537            thread::sleep(Duration::from_secs(POLLING_DELAY));
538        }
539    }
540
541    /// Get the most recent modification time across all namespace values.json files
542    /// Returns None if no valid values files are found
543    fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
544        let mut latest_mtime = None;
545
546        let entries = match fs::read_dir(values_dir) {
547            Ok(e) => e,
548            Err(e) => {
549                eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
550                return None;
551            }
552        };
553
554        for entry in entries.flatten() {
555            // skip if not a dir
556            if !entry
557                .file_type()
558                .map(|file_type| file_type.is_dir())
559                .unwrap_or(false)
560            {
561                continue;
562            }
563
564            let values_file = entry.path().join(VALUES_FILE_NAME);
565            if let Ok(metadata) = fs::metadata(&values_file)
566                && let Ok(mtime) = metadata.modified()
567                && latest_mtime.is_none_or(|latest| mtime > latest)
568            {
569                latest_mtime = Some(mtime);
570            }
571        }
572
573        latest_mtime
574    }
575
576    /// Reload values from disk, validate them, and update the shared map.
577    /// Emits a Sentry transaction per namespace with timing and propagation delay metrics.
578    fn reload_values(
579        values_path: &Path,
580        registry: &SchemaRegistry,
581        values: &Arc<RwLock<ValuesByNamespace>>,
582    ) {
583        let reload_start = Instant::now();
584
585        match registry.load_values_json(values_path) {
586            Ok((new_values, generated_at_by_namespace)) => {
587                let namespaces: Vec<String> = new_values.keys().cloned().collect();
588                Self::update_values(values, new_values);
589
590                let reload_duration = reload_start.elapsed();
591                Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
592            }
593            Err(e) => {
594                eprintln!(
595                    "Failed to reload values from {}: {}",
596                    values_path.display(),
597                    e
598                );
599            }
600        }
601    }
602
603    /// Emit a Sentry transaction per namespace with reload timing and propagation delay metrics.
604    /// Uses a dedicated Sentry Hub isolated from the host application's Sentry setup.
605    fn emit_reload_spans(
606        namespaces: &[String],
607        reload_duration: Duration,
608        generated_at_by_namespace: &HashMap<String, String>,
609    ) {
610        let hub = get_sentry_hub();
611        let applied_at = Utc::now();
612        let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
613
614        for namespace in namespaces {
615            let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
616            tx_ctx.set_sampled(true);
617
618            let transaction = hub.start_transaction(tx_ctx);
619            transaction.set_data("reload_duration_ms", reload_duration_ms.into());
620            transaction.set_data("applied_at", applied_at.to_rfc3339().into());
621
622            if let Some(ts) = generated_at_by_namespace.get(namespace) {
623                transaction.set_data("generated_at", ts.as_str().into());
624
625                if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
626                    let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
627                        .num_milliseconds() as f64
628                        / 1000.0;
629                    transaction.set_data("propagation_delay_secs", delay_secs.into());
630                }
631            }
632
633            transaction.finish();
634        }
635    }
636
637    /// Update the values map with the new values
638    fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
639        // safe to unwrap, we only have one thread and if it panics we die anyways
640        let mut guard = values.write().unwrap();
641        *guard = new_values;
642    }
643
644    /// Stops the watcher thread, waiting for it to join.
645    /// May take up to POLLING_DELAY seconds
646    pub fn stop(&mut self) {
647        self.stop_signal.store(true, Ordering::Relaxed);
648        if let Some(thread) = self.thread.take() {
649            let _ = thread.join();
650        }
651    }
652
653    /// Returns whether the watcher thread is still running
654    pub fn is_alive(&self) -> bool {
655        self.thread.as_ref().is_some_and(|t| !t.is_finished())
656    }
657}
658
659impl Drop for ValuesWatcher {
660    fn drop(&mut self) {
661        self.stop();
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use tempfile::TempDir;
669
670    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
671        let schema_dir = temp_dir.path().join(namespace);
672        fs::create_dir_all(&schema_dir).unwrap();
673        let schema_file = schema_dir.join("schema.json");
674        fs::write(&schema_file, schema_json).unwrap();
675        schema_file
676    }
677
678    #[test]
679    fn test_validate_k8s_name_component_valid() {
680        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
681        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
682        assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
683        assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
684    }
685
686    #[test]
687    fn test_validate_k8s_name_component_rejects_uppercase() {
688        let result = validate_k8s_name_component("MyService", "namespace");
689        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
690        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
691    }
692
693    #[test]
694    fn test_validate_k8s_name_component_rejects_underscore() {
695        let result = validate_k8s_name_component("my_service", "target");
696        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
697        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
698    }
699
700    #[test]
701    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
702        let result = validate_k8s_name_component("-service", "namespace");
703        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
704        assert!(
705            result
706                .unwrap_err()
707                .to_string()
708                .contains("start and end with alphanumeric")
709        );
710    }
711
712    #[test]
713    fn test_validate_k8s_name_component_rejects_trailing_dot() {
714        let result = validate_k8s_name_component("service.", "namespace");
715        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
716        assert!(
717            result
718                .unwrap_err()
719                .to_string()
720                .contains("start and end with alphanumeric")
721        );
722    }
723
724    #[test]
725    fn test_load_schema_valid() {
726        let temp_dir = TempDir::new().unwrap();
727        create_test_schema(
728            &temp_dir,
729            "test",
730            r#"{
731                "version": "1.0",
732                "type": "object",
733                "properties": {
734                    "test-key": {
735                        "type": "string",
736                        "default": "test",
737                        "description": "Test option"
738                    }
739                }
740            }"#,
741        );
742
743        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
744    }
745
746    #[test]
747    fn test_load_schema_missing_version() {
748        let temp_dir = TempDir::new().unwrap();
749        create_test_schema(
750            &temp_dir,
751            "test",
752            r#"{
753                "type": "object",
754                "properties": {}
755            }"#,
756        );
757
758        let result = SchemaRegistry::from_directory(temp_dir.path());
759        assert!(result.is_err());
760        match result {
761            Err(ValidationError::SchemaError { message, .. }) => {
762                assert!(message.contains(
763                    "Schema validation failed:
764Error: \"version\" is a required property"
765                ));
766            }
767            _ => panic!("Expected SchemaError for missing version"),
768        }
769    }
770
771    #[test]
772    fn test_unknown_namespace() {
773        let temp_dir = TempDir::new().unwrap();
774        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
775
776        let result = registry.validate_values("unknown", &json!({}));
777        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
778    }
779
780    #[test]
781    fn test_multiple_namespaces() {
782        let temp_dir = TempDir::new().unwrap();
783        create_test_schema(
784            &temp_dir,
785            "ns1",
786            r#"{
787                "version": "1.0",
788                "type": "object",
789                "properties": {
790                    "opt1": {
791                        "type": "string",
792                        "default": "default1",
793                        "description": "First option"
794                    }
795                }
796            }"#,
797        );
798        create_test_schema(
799            &temp_dir,
800            "ns2",
801            r#"{
802                "version": "2.0",
803                "type": "object",
804                "properties": {
805                    "opt2": {
806                        "type": "integer",
807                        "default": 42,
808                        "description": "Second option"
809                    }
810                }
811            }"#,
812        );
813
814        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
815        assert!(registry.schemas.contains_key("ns1"));
816        assert!(registry.schemas.contains_key("ns2"));
817    }
818
819    #[test]
820    fn test_invalid_default_type() {
821        let temp_dir = TempDir::new().unwrap();
822        create_test_schema(
823            &temp_dir,
824            "test",
825            r#"{
826                "version": "1.0",
827                "type": "object",
828                "properties": {
829                    "bad-default": {
830                        "type": "integer",
831                        "default": "not-a-number",
832                        "description": "A bad default value"
833                    }
834                }
835            }"#,
836        );
837
838        let result = SchemaRegistry::from_directory(temp_dir.path());
839        assert!(result.is_err());
840        match result {
841            Err(ValidationError::SchemaError { message, .. }) => {
842                assert!(message.contains("Property 'bad-default': default value does not match type 'integer': \"not-a-number\" is not of type \"integer\""));
843            }
844            _ => panic!("Expected SchemaError for invalid default type"),
845        }
846    }
847
848    #[test]
849    fn test_extra_properties() {
850        let temp_dir = TempDir::new().unwrap();
851        create_test_schema(
852            &temp_dir,
853            "test",
854            r#"{
855                "version": "1.0",
856                "type": "object",
857                "properties": {
858                    "bad-property": {
859                        "type": "integer",
860                        "default": 0,
861                        "description": "Test property",
862                        "extra": "property"
863                    }
864                }
865            }"#,
866        );
867
868        let result = SchemaRegistry::from_directory(temp_dir.path());
869        assert!(result.is_err());
870        match result {
871            Err(ValidationError::SchemaError { message, .. }) => {
872                assert!(
873                    message
874                        .contains("Additional properties are not allowed ('extra' was unexpected)")
875                );
876            }
877            _ => panic!("Expected SchemaError for extra properties"),
878        }
879    }
880
881    #[test]
882    fn test_missing_description() {
883        let temp_dir = TempDir::new().unwrap();
884        create_test_schema(
885            &temp_dir,
886            "test",
887            r#"{
888                "version": "1.0",
889                "type": "object",
890                "properties": {
891                    "missing-desc": {
892                        "type": "string",
893                        "default": "test"
894                    }
895                }
896            }"#,
897        );
898
899        let result = SchemaRegistry::from_directory(temp_dir.path());
900        assert!(result.is_err());
901        match result {
902            Err(ValidationError::SchemaError { message, .. }) => {
903                assert!(message.contains("\"description\" is a required property"));
904            }
905            _ => panic!("Expected SchemaError for missing description"),
906        }
907    }
908
909    #[test]
910    fn test_invalid_directory_structure() {
911        let temp_dir = TempDir::new().unwrap();
912        // Create a namespace directory without schema.json file
913        let schema_dir = temp_dir.path().join("missing-schema");
914        fs::create_dir_all(&schema_dir).unwrap();
915
916        let result = SchemaRegistry::from_directory(temp_dir.path());
917        assert!(result.is_err());
918        match result {
919            Err(ValidationError::FileRead(..)) => {
920                // Expected error when schema.json file is missing
921            }
922            _ => panic!("Expected FileRead error for missing schema.json"),
923        }
924    }
925
926    #[test]
927    fn test_get_default() {
928        let temp_dir = TempDir::new().unwrap();
929        create_test_schema(
930            &temp_dir,
931            "test",
932            r#"{
933                "version": "1.0",
934                "type": "object",
935                "properties": {
936                    "string_opt": {
937                        "type": "string",
938                        "default": "hello",
939                        "description": "A string option"
940                    },
941                    "int_opt": {
942                        "type": "integer",
943                        "default": 42,
944                        "description": "An integer option"
945                    }
946                }
947            }"#,
948        );
949
950        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
951        let schema = registry.get("test").unwrap();
952
953        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
954        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
955        assert_eq!(schema.get_default("unknown"), None);
956    }
957
958    #[test]
959    fn test_validate_values_valid() {
960        let temp_dir = TempDir::new().unwrap();
961        create_test_schema(
962            &temp_dir,
963            "test",
964            r#"{
965                "version": "1.0",
966                "type": "object",
967                "properties": {
968                    "enabled": {
969                        "type": "boolean",
970                        "default": false,
971                        "description": "Enable feature"
972                    }
973                }
974            }"#,
975        );
976
977        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
978        let result = registry.validate_values("test", &json!({"enabled": true}));
979        assert!(result.is_ok());
980    }
981
982    #[test]
983    fn test_validate_values_invalid_type() {
984        let temp_dir = TempDir::new().unwrap();
985        create_test_schema(
986            &temp_dir,
987            "test",
988            r#"{
989                "version": "1.0",
990                "type": "object",
991                "properties": {
992                    "count": {
993                        "type": "integer",
994                        "default": 0,
995                        "description": "Count"
996                    }
997                }
998            }"#,
999        );
1000
1001        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1002        let result = registry.validate_values("test", &json!({"count": "not a number"}));
1003        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1004    }
1005
1006    #[test]
1007    fn test_validate_values_unknown_option() {
1008        let temp_dir = TempDir::new().unwrap();
1009        create_test_schema(
1010            &temp_dir,
1011            "test",
1012            r#"{
1013                "version": "1.0",
1014                "type": "object",
1015                "properties": {
1016                    "known_option": {
1017                        "type": "string",
1018                        "default": "default",
1019                        "description": "A known option"
1020                    }
1021                }
1022            }"#,
1023        );
1024
1025        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1026
1027        // Valid known option should pass
1028        let result = registry.validate_values("test", &json!({"known_option": "value"}));
1029        assert!(result.is_ok());
1030
1031        // Unknown option should fail
1032        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1033        assert!(result.is_err());
1034        match result {
1035            Err(ValidationError::ValueError { errors, .. }) => {
1036                assert!(errors.contains("Additional properties are not allowed"));
1037            }
1038            _ => panic!("Expected ValueError for unknown option"),
1039        }
1040    }
1041
1042    #[test]
1043    fn test_load_values_json_valid() {
1044        let temp_dir = TempDir::new().unwrap();
1045        let schemas_dir = temp_dir.path().join("schemas");
1046        let values_dir = temp_dir.path().join("values");
1047
1048        let schema_dir = schemas_dir.join("test");
1049        fs::create_dir_all(&schema_dir).unwrap();
1050        fs::write(
1051            schema_dir.join("schema.json"),
1052            r#"{
1053                "version": "1.0",
1054                "type": "object",
1055                "properties": {
1056                    "enabled": {
1057                        "type": "boolean",
1058                        "default": false,
1059                        "description": "Enable feature"
1060                    },
1061                    "name": {
1062                        "type": "string",
1063                        "default": "default",
1064                        "description": "Name"
1065                    },
1066                    "count": {
1067                        "type": "integer",
1068                        "default": 0,
1069                        "description": "Count"
1070                    },
1071                    "rate": {
1072                        "type": "number",
1073                        "default": 0.0,
1074                        "description": "Rate"
1075                    }
1076                }
1077            }"#,
1078        )
1079        .unwrap();
1080
1081        let test_values_dir = values_dir.join("test");
1082        fs::create_dir_all(&test_values_dir).unwrap();
1083        fs::write(
1084            test_values_dir.join("values.json"),
1085            r#"{
1086                "options": {
1087                    "enabled": true,
1088                    "name": "test-name",
1089                    "count": 42,
1090                    "rate": 0.75
1091                }
1092            }"#,
1093        )
1094        .unwrap();
1095
1096        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1097        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1098
1099        assert_eq!(values.len(), 1);
1100        assert_eq!(values["test"]["enabled"], json!(true));
1101        assert_eq!(values["test"]["name"], json!("test-name"));
1102        assert_eq!(values["test"]["count"], json!(42));
1103        assert_eq!(values["test"]["rate"], json!(0.75));
1104        assert!(generated_at_by_namespace.is_empty());
1105    }
1106
1107    #[test]
1108    fn test_load_values_json_nonexistent_dir() {
1109        let temp_dir = TempDir::new().unwrap();
1110        create_test_schema(
1111            &temp_dir,
1112            "test",
1113            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1114        );
1115
1116        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1117        let (values, generated_at_by_namespace) = registry
1118            .load_values_json(&temp_dir.path().join("nonexistent"))
1119            .unwrap();
1120
1121        // No values.json files found, returns empty
1122        assert!(values.is_empty());
1123        assert!(generated_at_by_namespace.is_empty());
1124    }
1125
1126    #[test]
1127    fn test_load_values_json_skips_missing_values_file() {
1128        let temp_dir = TempDir::new().unwrap();
1129        let schemas_dir = temp_dir.path().join("schemas");
1130        let values_dir = temp_dir.path().join("values");
1131
1132        // Create two schemas
1133        let schema_dir1 = schemas_dir.join("with-values");
1134        fs::create_dir_all(&schema_dir1).unwrap();
1135        fs::write(
1136            schema_dir1.join("schema.json"),
1137            r#"{
1138                "version": "1.0",
1139                "type": "object",
1140                "properties": {
1141                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1142                }
1143            }"#,
1144        )
1145        .unwrap();
1146
1147        let schema_dir2 = schemas_dir.join("without-values");
1148        fs::create_dir_all(&schema_dir2).unwrap();
1149        fs::write(
1150            schema_dir2.join("schema.json"),
1151            r#"{
1152                "version": "1.0",
1153                "type": "object",
1154                "properties": {
1155                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1156                }
1157            }"#,
1158        )
1159        .unwrap();
1160
1161        // Only create values for one namespace
1162        let with_values_dir = values_dir.join("with-values");
1163        fs::create_dir_all(&with_values_dir).unwrap();
1164        fs::write(
1165            with_values_dir.join("values.json"),
1166            r#"{"options": {"opt": "y"}}"#,
1167        )
1168        .unwrap();
1169
1170        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1171        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1172
1173        assert_eq!(values.len(), 1);
1174        assert!(values.contains_key("with-values"));
1175        assert!(!values.contains_key("without-values"));
1176    }
1177
1178    #[test]
1179    fn test_load_values_json_extracts_generated_at() {
1180        let temp_dir = TempDir::new().unwrap();
1181        let schemas_dir = temp_dir.path().join("schemas");
1182        let values_dir = temp_dir.path().join("values");
1183
1184        let schema_dir = schemas_dir.join("test");
1185        fs::create_dir_all(&schema_dir).unwrap();
1186        fs::write(
1187            schema_dir.join("schema.json"),
1188            r#"{
1189                "version": "1.0",
1190                "type": "object",
1191                "properties": {
1192                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1193                }
1194            }"#,
1195        )
1196        .unwrap();
1197
1198        let test_values_dir = values_dir.join("test");
1199        fs::create_dir_all(&test_values_dir).unwrap();
1200        fs::write(
1201            test_values_dir.join("values.json"),
1202            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1203        )
1204        .unwrap();
1205
1206        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1207        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1208
1209        assert_eq!(values["test"]["enabled"], json!(true));
1210        assert_eq!(
1211            generated_at_by_namespace.get("test"),
1212            Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1213        );
1214    }
1215
1216    #[test]
1217    fn test_load_values_json_rejects_wrong_type() {
1218        let temp_dir = TempDir::new().unwrap();
1219        let schemas_dir = temp_dir.path().join("schemas");
1220        let values_dir = temp_dir.path().join("values");
1221
1222        let schema_dir = schemas_dir.join("test");
1223        fs::create_dir_all(&schema_dir).unwrap();
1224        fs::write(
1225            schema_dir.join("schema.json"),
1226            r#"{
1227                "version": "1.0",
1228                "type": "object",
1229                "properties": {
1230                    "count": {"type": "integer", "default": 0, "description": "Count"}
1231                }
1232            }"#,
1233        )
1234        .unwrap();
1235
1236        let test_values_dir = values_dir.join("test");
1237        fs::create_dir_all(&test_values_dir).unwrap();
1238        fs::write(
1239            test_values_dir.join("values.json"),
1240            r#"{"options": {"count": "not-a-number"}}"#,
1241        )
1242        .unwrap();
1243
1244        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1245        let result = registry.load_values_json(&values_dir);
1246
1247        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1248    }
1249
1250    mod watcher_tests {
1251        use super::*;
1252        use std::thread;
1253
1254        /// Creates schema and values files for two namespaces: ns1, and ns2
1255        fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1256            let temp_dir = TempDir::new().unwrap();
1257            let schemas_dir = temp_dir.path().join("schemas");
1258            let values_dir = temp_dir.path().join("values");
1259
1260            let ns1_schema = schemas_dir.join("ns1");
1261            fs::create_dir_all(&ns1_schema).unwrap();
1262            fs::write(
1263                ns1_schema.join("schema.json"),
1264                r#"{
1265                    "version": "1.0",
1266                    "type": "object",
1267                    "properties": {
1268                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1269                    }
1270                }"#,
1271            )
1272            .unwrap();
1273
1274            let ns1_values = values_dir.join("ns1");
1275            fs::create_dir_all(&ns1_values).unwrap();
1276            fs::write(
1277                ns1_values.join("values.json"),
1278                r#"{"options": {"enabled": true}}"#,
1279            )
1280            .unwrap();
1281
1282            let ns2_schema = schemas_dir.join("ns2");
1283            fs::create_dir_all(&ns2_schema).unwrap();
1284            fs::write(
1285                ns2_schema.join("schema.json"),
1286                r#"{
1287                    "version": "1.0",
1288                    "type": "object",
1289                    "properties": {
1290                        "count": {"type": "integer", "default": 0, "description": "Count"}
1291                    }
1292                }"#,
1293            )
1294            .unwrap();
1295
1296            let ns2_values = values_dir.join("ns2");
1297            fs::create_dir_all(&ns2_values).unwrap();
1298            fs::write(
1299                ns2_values.join("values.json"),
1300                r#"{"options": {"count": 42}}"#,
1301            )
1302            .unwrap();
1303
1304            (temp_dir, schemas_dir, values_dir)
1305        }
1306
1307        #[test]
1308        fn test_get_mtime_returns_most_recent() {
1309            let (_temp, _schemas, values_dir) = setup_watcher_test();
1310
1311            // Get initial mtime
1312            let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1313            assert!(mtime1.is_some());
1314
1315            // Modify one namespace
1316            thread::sleep(std::time::Duration::from_millis(10));
1317            fs::write(
1318                values_dir.join("ns1").join("values.json"),
1319                r#"{"options": {"enabled": false}}"#,
1320            )
1321            .unwrap();
1322
1323            // Should detect the change
1324            let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1325            assert!(mtime2.is_some());
1326            assert!(mtime2 > mtime1);
1327        }
1328
1329        #[test]
1330        fn test_get_mtime_with_missing_directory() {
1331            let temp = TempDir::new().unwrap();
1332            let nonexistent = temp.path().join("nonexistent");
1333
1334            let mtime = ValuesWatcher::get_mtime(&nonexistent);
1335            assert!(mtime.is_none());
1336        }
1337
1338        #[test]
1339        fn test_reload_values_updates_map() {
1340            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1341
1342            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1343            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1344            let values = Arc::new(RwLock::new(initial_values));
1345
1346            // ensure initial values are correct
1347            {
1348                let guard = values.read().unwrap();
1349                assert_eq!(guard["ns1"]["enabled"], json!(true));
1350                assert_eq!(guard["ns2"]["count"], json!(42));
1351            }
1352
1353            // modify
1354            fs::write(
1355                values_dir.join("ns1").join("values.json"),
1356                r#"{"options": {"enabled": false}}"#,
1357            )
1358            .unwrap();
1359            fs::write(
1360                values_dir.join("ns2").join("values.json"),
1361                r#"{"options": {"count": 100}}"#,
1362            )
1363            .unwrap();
1364
1365            // force a reload
1366            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1367
1368            // ensure new values are correct
1369            {
1370                let guard = values.read().unwrap();
1371                assert_eq!(guard["ns1"]["enabled"], json!(false));
1372                assert_eq!(guard["ns2"]["count"], json!(100));
1373            }
1374        }
1375
1376        #[test]
1377        fn test_old_values_persist_with_invalid_data() {
1378            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1379
1380            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1381            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1382            let values = Arc::new(RwLock::new(initial_values));
1383
1384            let initial_enabled = {
1385                let guard = values.read().unwrap();
1386                guard["ns1"]["enabled"].clone()
1387            };
1388
1389            // won't pass validation
1390            fs::write(
1391                values_dir.join("ns1").join("values.json"),
1392                r#"{"options": {"enabled": "not-a-boolean"}}"#,
1393            )
1394            .unwrap();
1395
1396            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1397
1398            // ensure old value persists
1399            {
1400                let guard = values.read().unwrap();
1401                assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1402            }
1403        }
1404
1405        #[test]
1406        fn test_watcher_creation_and_termination() {
1407            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1408
1409            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1410            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1411            let values = Arc::new(RwLock::new(initial_values));
1412
1413            let mut watcher =
1414                ValuesWatcher::new(&values_dir, Arc::clone(&registry), Arc::clone(&values))
1415                    .expect("Failed to create watcher");
1416
1417            assert!(watcher.is_alive());
1418            watcher.stop();
1419            assert!(!watcher.is_alive());
1420        }
1421    }
1422}