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/// Environment variable to suppress missing directory errors
75pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
76
77/// Check if missing directory errors should be suppressed
78fn 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
84/// Resolve options directory using fallback chain:
85/// 1. `SENTRY_OPTIONS_DIR` env var (if set)
86/// 2. `/etc/sentry-options` (if exists)
87/// 3. `sentry-options/` (local fallback)
88pub 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
101/// Result type for validation operations
102pub type ValidationResult<T> = Result<T, ValidationError>;
103
104/// A map of option values keyed by their namespace
105pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
106
107/// Errors that can occur during schema and value validation
108#[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
139/// Validate a name component is valid for K8s (lowercase alphanumeric, '-', '.')
140pub 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/// Metadata for a single option in a namespace schema
167#[derive(Debug, Clone)]
168pub struct OptionMetadata {
169    pub option_type: String,
170    pub default: Value,
171}
172
173/// Schema for a namespace, containing validator and option metadata
174pub struct NamespaceSchema {
175    pub namespace: String,
176    pub options: HashMap<String, OptionMetadata>,
177    validator: jsonschema::Validator,
178}
179
180impl NamespaceSchema {
181    /// Validate an entire values object against this schema
182    ///
183    /// # Arguments
184    /// * `values` - JSON object containing option key-value pairs
185    ///
186    /// # Errors
187    /// Returns error if values don't match the schema
188    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
189        let output = self.validator.evaluate(values);
190        if output.flag().valid {
191            Ok(())
192        } else {
193            let errors: Vec<String> = output
194                .iter_errors()
195                .map(|e| {
196                    format!(
197                        "\n\t{} {}",
198                        e.instance_location.as_str().trim_start_matches("/"),
199                        e.error
200                    )
201                })
202                .collect();
203            Err(ValidationError::ValueError {
204                namespace: self.namespace.clone(),
205                errors: errors.join(""),
206            })
207        }
208    }
209
210    /// Get the default value for an option key.
211    /// Returns None if the key doesn't exist in the schema.
212    pub fn get_default(&self, key: &str) -> Option<&Value> {
213        self.options.get(key).map(|meta| &meta.default)
214    }
215}
216
217/// Registry for loading and storing schemas
218pub struct SchemaRegistry {
219    schemas: HashMap<String, Arc<NamespaceSchema>>,
220}
221
222impl SchemaRegistry {
223    /// Create a new empty schema registry
224    pub fn new() -> Self {
225        Self {
226            schemas: HashMap::new(),
227        }
228    }
229
230    /// Load schemas from a directory and create a registry
231    ///
232    /// Expects directory structure: `schemas/{namespace}/schema.json`
233    ///
234    /// # Arguments
235    /// * `schemas_dir` - Path to directory containing namespace subdirectories
236    ///
237    /// # Errors
238    /// Returns error if directory doesn't exist or any schema is invalid
239    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
240        let schemas = Self::load_all_schemas(schemas_dir)?;
241        Ok(Self { schemas })
242    }
243
244    /// Validate an entire values object for a namespace
245    ///
246    /// # Arguments
247    /// * `namespace` - Namespace name
248    /// * `values` - JSON object containing option key-value pairs
249    ///
250    /// # Errors
251    /// Returns error if namespace doesn't exist or values don't match schema
252    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
253        let schema = self
254            .schemas
255            .get(namespace)
256            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
257
258        schema.validate_values(values)
259    }
260
261    /// Load all schemas from a directory
262    fn load_all_schemas(
263        schemas_dir: &Path,
264    ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
265        // Compile namespace-schema once for all schemas
266        let namespace_schema_value: Value =
267            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
268                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
269            })?;
270        let namespace_validator =
271            jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
272                ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
273            })?;
274
275        let mut schemas = HashMap::new();
276
277        // TODO: Parallelize the loading of schemas for the performance gainz
278        for entry in fs::read_dir(schemas_dir)? {
279            let entry = entry?;
280
281            if !entry.file_type()?.is_dir() {
282                continue;
283            }
284
285            let namespace =
286                entry
287                    .file_name()
288                    .into_string()
289                    .map_err(|_| ValidationError::SchemaError {
290                        file: entry.path(),
291                        message: "Directory name contains invalid UTF-8".to_string(),
292                    })?;
293
294            validate_k8s_name_component(&namespace, "namespace name")?;
295
296            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
297            let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
298            schemas.insert(namespace, schema);
299        }
300
301        Ok(schemas)
302    }
303
304    /// Load a schema from a file
305    fn load_schema(
306        path: &Path,
307        namespace: &str,
308        namespace_validator: &jsonschema::Validator,
309    ) -> ValidationResult<Arc<NamespaceSchema>> {
310        let file = fs::File::open(path)?;
311        let schema_data: Value = serde_json::from_reader(file)?;
312
313        Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
314        Self::parse_schema(schema_data, namespace, path)
315    }
316
317    /// Validate a schema against the namespace-schema
318    fn validate_with_namespace_schema(
319        schema_data: &Value,
320        path: &Path,
321        namespace_validator: &jsonschema::Validator,
322    ) -> ValidationResult<()> {
323        let output = namespace_validator.evaluate(schema_data);
324
325        if output.flag().valid {
326            Ok(())
327        } else {
328            let errors: Vec<String> = output
329                .iter_errors()
330                .map(|e| format!("Error: {}", e.error))
331                .collect();
332
333            Err(ValidationError::SchemaError {
334                file: path.to_path_buf(),
335                message: format!("Schema validation failed:\n{}", errors.join("\n")),
336            })
337        }
338    }
339
340    /// Validate that a default value matches its declared type using jsonschema
341    fn validate_default_type(
342        property_name: &str,
343        property_type: &str,
344        default_value: &Value,
345        path: &Path,
346    ) -> ValidationResult<()> {
347        // Build a mini JSON Schema for just this type
348        let type_schema = serde_json::json!({
349            "type": property_type
350        });
351
352        // Validate the default value against the type
353        jsonschema::validate(&type_schema, default_value).map_err(|e| {
354            ValidationError::SchemaError {
355                file: path.to_path_buf(),
356                message: format!(
357                    "Property '{}': default value does not match type '{}': {}",
358                    property_name, property_type, e
359                ),
360            }
361        })?;
362
363        Ok(())
364    }
365
366    /// Parse a schema JSON into NamespaceSchema
367    fn parse_schema(
368        mut schema: Value,
369        namespace: &str,
370        path: &Path,
371    ) -> ValidationResult<Arc<NamespaceSchema>> {
372        // Inject additionalProperties: false to reject unknown options
373        if let Some(obj) = schema.as_object_mut() {
374            obj.insert("additionalProperties".to_string(), json!(false));
375        }
376
377        // Use the schema file directly as the validator
378        let validator =
379            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
380                file: path.to_path_buf(),
381                message: format!("Failed to compile validator: {}", e),
382            })?;
383
384        // Extract option metadata and validate types
385        let mut options = HashMap::new();
386        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
387            for (prop_name, prop_value) in properties {
388                if let (Some(prop_type), Some(default_value)) = (
389                    prop_value.get("type").and_then(|t| t.as_str()),
390                    prop_value.get("default"),
391                ) {
392                    Self::validate_default_type(prop_name, prop_type, default_value, path)?;
393                    options.insert(
394                        prop_name.clone(),
395                        OptionMetadata {
396                            option_type: prop_type.to_string(),
397                            default: default_value.clone(),
398                        },
399                    );
400                }
401            }
402        }
403
404        Ok(Arc::new(NamespaceSchema {
405            namespace: namespace.to_string(),
406            options,
407            validator,
408        }))
409    }
410
411    /// Get a namespace schema by name
412    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
413        self.schemas.get(namespace)
414    }
415
416    /// Get all loaded schemas (for schema evolution validation)
417    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
418        &self.schemas
419    }
420
421    /// Load and validate JSON values from a directory.
422    /// Expects structure: `{values_dir}/{namespace}/values.json`
423    /// Values file must have format: `{"options": {"key": value, ...}, "generated_at": "..."}`
424    /// Skips namespaces without a values.json file.
425    /// Returns the values and a map of namespace -> `generated_at` timestamp.
426    pub fn load_values_json(
427        &self,
428        values_dir: &Path,
429    ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
430        let mut all_values = HashMap::new();
431        let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
432
433        for namespace in self.schemas.keys() {
434            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
435
436            if !values_file.exists() {
437                continue;
438            }
439
440            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
441
442            // Extract generated_at if present
443            if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
444                generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
445            }
446
447            let values = parsed
448                .get("options")
449                .ok_or_else(|| ValidationError::ValueError {
450                    namespace: namespace.clone(),
451                    errors: "values.json must have an 'options' key".to_string(),
452                })?;
453
454            self.validate_values(namespace, values)?;
455
456            if let Value::Object(obj) = values.clone() {
457                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
458                all_values.insert(namespace.clone(), ns_values);
459            }
460        }
461
462        Ok((all_values, generated_at_by_namespace))
463    }
464}
465
466impl Default for SchemaRegistry {
467    fn default() -> Self {
468        Self::new()
469    }
470}
471
472/// Watches the values directory for changes, reloading if there are any.
473/// If the directory does not exist we do not panic
474///
475/// Does not do an initial fetch, assumes the caller has already loaded values.
476/// Child thread may panic if we run out of memory or cannot create more threads.
477///
478/// Uses polling for now, could use `inotify` or similar later on.
479///
480/// Some important notes:
481/// - If the thread panics and dies, there is no built in mechanism to catch it and restart
482/// - If a config map is unmounted, we won't reload until the next file modification (because we don't catch the deletion event)
483/// - If any namespace fails validation, we keep all old values (even the namespaces that passed validation)
484/// - If we have a steady stream of readers our writer may starve for a while trying to acquire the lock
485/// - stop() will block until the thread gets joined
486pub struct ValuesWatcher {
487    stop_signal: Arc<AtomicBool>,
488    thread: Option<JoinHandle<()>>,
489}
490
491impl ValuesWatcher {
492    /// Creates a new ValuesWatcher struct and spins up the watcher thread
493    pub fn new(
494        values_path: &Path,
495        registry: Arc<SchemaRegistry>,
496        values: Arc<RwLock<ValuesByNamespace>>,
497    ) -> ValidationResult<Self> {
498        // output an error but keep passing
499        if !should_suppress_missing_dir_errors() && fs::metadata(values_path).is_err() {
500            eprintln!("Values directory does not exist: {}", values_path.display());
501        }
502
503        let stop_signal = Arc::new(AtomicBool::new(false));
504
505        let thread_signal = Arc::clone(&stop_signal);
506        let thread_path = values_path.to_path_buf();
507        let thread_registry = Arc::clone(&registry);
508        let thread_values = Arc::clone(&values);
509        let thread = thread::Builder::new()
510            .name("sentry-options-watcher".into())
511            .spawn(move || {
512                let result = panic::catch_unwind(AssertUnwindSafe(|| {
513                    Self::run(thread_signal, thread_path, thread_registry, thread_values);
514                }));
515                if let Err(e) = result {
516                    eprintln!("Watcher thread panicked with: {:?}", e);
517                }
518            })?;
519
520        Ok(Self {
521            stop_signal,
522            thread: Some(thread),
523        })
524    }
525
526    /// Reloads the values if the modified time has changed.
527    ///
528    /// Continuously polls the values directory and reloads all values
529    /// if any modification is detected.
530    fn run(
531        stop_signal: Arc<AtomicBool>,
532        values_path: PathBuf,
533        registry: Arc<SchemaRegistry>,
534        values: Arc<RwLock<ValuesByNamespace>>,
535    ) {
536        let mut last_mtime = Self::get_mtime(&values_path);
537
538        while !stop_signal.load(Ordering::Relaxed) {
539            // does not reload values if get_mtime fails
540            if let Some(current_mtime) = Self::get_mtime(&values_path)
541                && Some(current_mtime) != last_mtime
542            {
543                Self::reload_values(&values_path, &registry, &values);
544                last_mtime = Some(current_mtime);
545            }
546
547            thread::sleep(Duration::from_secs(POLLING_DELAY));
548        }
549    }
550
551    /// Get the most recent modification time across all namespace values.json files
552    /// Returns None if no valid values files are found
553    fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
554        let mut latest_mtime = None;
555
556        let entries = match fs::read_dir(values_dir) {
557            Ok(e) => e,
558            Err(e) => {
559                if !should_suppress_missing_dir_errors() {
560                    eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
561                }
562                return None;
563            }
564        };
565
566        for entry in entries.flatten() {
567            // skip if not a dir
568            if !entry
569                .file_type()
570                .map(|file_type| file_type.is_dir())
571                .unwrap_or(false)
572            {
573                continue;
574            }
575
576            let values_file = entry.path().join(VALUES_FILE_NAME);
577            if let Ok(metadata) = fs::metadata(&values_file)
578                && let Ok(mtime) = metadata.modified()
579                && latest_mtime.is_none_or(|latest| mtime > latest)
580            {
581                latest_mtime = Some(mtime);
582            }
583        }
584
585        latest_mtime
586    }
587
588    /// Reload values from disk, validate them, and update the shared map.
589    /// Emits a Sentry transaction per namespace with timing and propagation delay metrics.
590    fn reload_values(
591        values_path: &Path,
592        registry: &SchemaRegistry,
593        values: &Arc<RwLock<ValuesByNamespace>>,
594    ) {
595        let reload_start = Instant::now();
596
597        match registry.load_values_json(values_path) {
598            Ok((new_values, generated_at_by_namespace)) => {
599                let namespaces: Vec<String> = new_values.keys().cloned().collect();
600                Self::update_values(values, new_values);
601
602                let reload_duration = reload_start.elapsed();
603                Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
604            }
605            Err(e) => {
606                eprintln!(
607                    "Failed to reload values from {}: {}",
608                    values_path.display(),
609                    e
610                );
611            }
612        }
613    }
614
615    /// Emit a Sentry transaction per namespace with reload timing and propagation delay metrics.
616    /// Uses a dedicated Sentry Hub isolated from the host application's Sentry setup.
617    fn emit_reload_spans(
618        namespaces: &[String],
619        reload_duration: Duration,
620        generated_at_by_namespace: &HashMap<String, String>,
621    ) {
622        let hub = get_sentry_hub();
623        let applied_at = Utc::now();
624        let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
625
626        for namespace in namespaces {
627            let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
628            tx_ctx.set_sampled(true);
629
630            let transaction = hub.start_transaction(tx_ctx);
631            transaction.set_data("reload_duration_ms", reload_duration_ms.into());
632            transaction.set_data("applied_at", applied_at.to_rfc3339().into());
633
634            if let Some(ts) = generated_at_by_namespace.get(namespace) {
635                transaction.set_data("generated_at", ts.as_str().into());
636
637                if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
638                    let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
639                        .num_milliseconds() as f64
640                        / 1000.0;
641                    transaction.set_data("propagation_delay_secs", delay_secs.into());
642                }
643            }
644
645            transaction.finish();
646        }
647    }
648
649    /// Update the values map with the new values
650    fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
651        // safe to unwrap, we only have one thread and if it panics we die anyways
652        let mut guard = values.write().unwrap();
653        *guard = new_values;
654    }
655
656    /// Stops the watcher thread, waiting for it to join.
657    /// May take up to POLLING_DELAY seconds
658    pub fn stop(&mut self) {
659        self.stop_signal.store(true, Ordering::Relaxed);
660        if let Some(thread) = self.thread.take() {
661            let _ = thread.join();
662        }
663    }
664
665    /// Returns whether the watcher thread is still running
666    pub fn is_alive(&self) -> bool {
667        self.thread.as_ref().is_some_and(|t| !t.is_finished())
668    }
669}
670
671impl Drop for ValuesWatcher {
672    fn drop(&mut self) {
673        self.stop();
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use tempfile::TempDir;
681
682    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
683        let schema_dir = temp_dir.path().join(namespace);
684        fs::create_dir_all(&schema_dir).unwrap();
685        let schema_file = schema_dir.join("schema.json");
686        fs::write(&schema_file, schema_json).unwrap();
687        schema_file
688    }
689
690    #[test]
691    fn test_validate_k8s_name_component_valid() {
692        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
693        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
694        assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
695        assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
696    }
697
698    #[test]
699    fn test_validate_k8s_name_component_rejects_uppercase() {
700        let result = validate_k8s_name_component("MyService", "namespace");
701        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
702        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
703    }
704
705    #[test]
706    fn test_validate_k8s_name_component_rejects_underscore() {
707        let result = validate_k8s_name_component("my_service", "target");
708        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
709        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
710    }
711
712    #[test]
713    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
714        let result = validate_k8s_name_component("-service", "namespace");
715        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
716        assert!(
717            result
718                .unwrap_err()
719                .to_string()
720                .contains("start and end with alphanumeric")
721        );
722    }
723
724    #[test]
725    fn test_validate_k8s_name_component_rejects_trailing_dot() {
726        let result = validate_k8s_name_component("service.", "namespace");
727        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
728        assert!(
729            result
730                .unwrap_err()
731                .to_string()
732                .contains("start and end with alphanumeric")
733        );
734    }
735
736    #[test]
737    fn test_load_schema_valid() {
738        let temp_dir = TempDir::new().unwrap();
739        create_test_schema(
740            &temp_dir,
741            "test",
742            r#"{
743                "version": "1.0",
744                "type": "object",
745                "properties": {
746                    "test-key": {
747                        "type": "string",
748                        "default": "test",
749                        "description": "Test option"
750                    }
751                }
752            }"#,
753        );
754
755        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
756    }
757
758    #[test]
759    fn test_load_schema_missing_version() {
760        let temp_dir = TempDir::new().unwrap();
761        create_test_schema(
762            &temp_dir,
763            "test",
764            r#"{
765                "type": "object",
766                "properties": {}
767            }"#,
768        );
769
770        let result = SchemaRegistry::from_directory(temp_dir.path());
771        assert!(result.is_err());
772        match result {
773            Err(ValidationError::SchemaError { message, .. }) => {
774                assert!(message.contains(
775                    "Schema validation failed:
776Error: \"version\" is a required property"
777                ));
778            }
779            _ => panic!("Expected SchemaError for missing version"),
780        }
781    }
782
783    #[test]
784    fn test_unknown_namespace() {
785        let temp_dir = TempDir::new().unwrap();
786        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
787
788        let result = registry.validate_values("unknown", &json!({}));
789        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
790    }
791
792    #[test]
793    fn test_multiple_namespaces() {
794        let temp_dir = TempDir::new().unwrap();
795        create_test_schema(
796            &temp_dir,
797            "ns1",
798            r#"{
799                "version": "1.0",
800                "type": "object",
801                "properties": {
802                    "opt1": {
803                        "type": "string",
804                        "default": "default1",
805                        "description": "First option"
806                    }
807                }
808            }"#,
809        );
810        create_test_schema(
811            &temp_dir,
812            "ns2",
813            r#"{
814                "version": "2.0",
815                "type": "object",
816                "properties": {
817                    "opt2": {
818                        "type": "integer",
819                        "default": 42,
820                        "description": "Second option"
821                    }
822                }
823            }"#,
824        );
825
826        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
827        assert!(registry.schemas.contains_key("ns1"));
828        assert!(registry.schemas.contains_key("ns2"));
829    }
830
831    #[test]
832    fn test_invalid_default_type() {
833        let temp_dir = TempDir::new().unwrap();
834        create_test_schema(
835            &temp_dir,
836            "test",
837            r#"{
838                "version": "1.0",
839                "type": "object",
840                "properties": {
841                    "bad-default": {
842                        "type": "integer",
843                        "default": "not-a-number",
844                        "description": "A bad default value"
845                    }
846                }
847            }"#,
848        );
849
850        let result = SchemaRegistry::from_directory(temp_dir.path());
851        assert!(result.is_err());
852        match result {
853            Err(ValidationError::SchemaError { message, .. }) => {
854                assert!(message.contains("Property 'bad-default': default value does not match type 'integer': \"not-a-number\" is not of type \"integer\""));
855            }
856            _ => panic!("Expected SchemaError for invalid default type"),
857        }
858    }
859
860    #[test]
861    fn test_extra_properties() {
862        let temp_dir = TempDir::new().unwrap();
863        create_test_schema(
864            &temp_dir,
865            "test",
866            r#"{
867                "version": "1.0",
868                "type": "object",
869                "properties": {
870                    "bad-property": {
871                        "type": "integer",
872                        "default": 0,
873                        "description": "Test property",
874                        "extra": "property"
875                    }
876                }
877            }"#,
878        );
879
880        let result = SchemaRegistry::from_directory(temp_dir.path());
881        assert!(result.is_err());
882        match result {
883            Err(ValidationError::SchemaError { message, .. }) => {
884                assert!(
885                    message
886                        .contains("Additional properties are not allowed ('extra' was unexpected)")
887                );
888            }
889            _ => panic!("Expected SchemaError for extra properties"),
890        }
891    }
892
893    #[test]
894    fn test_missing_description() {
895        let temp_dir = TempDir::new().unwrap();
896        create_test_schema(
897            &temp_dir,
898            "test",
899            r#"{
900                "version": "1.0",
901                "type": "object",
902                "properties": {
903                    "missing-desc": {
904                        "type": "string",
905                        "default": "test"
906                    }
907                }
908            }"#,
909        );
910
911        let result = SchemaRegistry::from_directory(temp_dir.path());
912        assert!(result.is_err());
913        match result {
914            Err(ValidationError::SchemaError { message, .. }) => {
915                assert!(message.contains("\"description\" is a required property"));
916            }
917            _ => panic!("Expected SchemaError for missing description"),
918        }
919    }
920
921    #[test]
922    fn test_invalid_directory_structure() {
923        let temp_dir = TempDir::new().unwrap();
924        // Create a namespace directory without schema.json file
925        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                // Expected error when schema.json file is missing
933            }
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        // Valid known option should pass
1040        let result = registry.validate_values("test", &json!({"known_option": "value"}));
1041        assert!(result.is_ok());
1042
1043        // Unknown option should fail
1044        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        // No values.json files found, returns empty
1134        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        // Create two schemas
1145        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        // Only create values for one namespace
1174        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        /// Creates schema and values files for two namespaces: ns1, and ns2
1267        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            // Get initial mtime
1324            let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1325            assert!(mtime1.is_some());
1326
1327            // Modify one namespace
1328            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            // Should detect the change
1336            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            // ensure initial values are correct
1359            {
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            // modify
1366            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            // force a reload
1378            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1379
1380            // ensure new values are correct
1381            {
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            // won't pass validation
1402            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, &registry, &values);
1409
1410            // ensure old value persists
1411            {
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(&registry), 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}