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