devops-validate 0.1.0

YAML validation and auto-repair engine for DevOps configuration files: Kubernetes, Docker Compose, GitLab CI, GitHub Actions, Prometheus, Alertmanager, Helm, and Ansible.
Documentation
//! Schema registry for fetching and caching JSON Schemas
//!
//! Provides schema URL resolution, remote fetching with caching, and
//! fallback to bundled schemas for offline mode.

use std::collections::HashMap;

use serde_json::Value;

use super::bundled;
use super::resolver::YamlType;

/// Schema URL templates for known YAML types
/// Format: (schema_key, url_template)
///
/// URL templates can contain `{version}` placeholder for K8s version.
pub const SCHEMA_URLS: &[(&str, &str)] = &[
    // Kubernetes (using kubernetesjsonschema.dev)
    ("k8s/deployment", "https://kubernetesjsonschema.dev/v{version}/_definitions.json#/definitions/io.k8s.api.apps.v1.Deployment"),
    ("k8s/service", "https://kubernetesjsonschema.dev/v{version}/service.json"),
    ("k8s/configmap", "https://kubernetesjsonschema.dev/v{version}/configmap.json"),
    ("k8s/secret", "https://kubernetesjsonschema.dev/v{version}/secret.json"),
    ("k8s/ingress", "https://kubernetesjsonschema.dev/v{version}/ingress.json"),
    ("k8s/horizontalpodautoscaler", "https://kubernetesjsonschema.dev/v{version}/horizontalpodautoscaler.json"),
    ("k8s/cronjob", "https://kubernetesjsonschema.dev/v{version}/cronjob.json"),
    ("k8s/job", "https://kubernetesjsonschema.dev/v{version}/job.json"),
    ("k8s/persistentvolumeclaim", "https://kubernetesjsonschema.dev/v{version}/persistentvolumeclaim.json"),
    ("k8s/networkpolicy", "https://kubernetesjsonschema.dev/v{version}/networkpolicy.json"),
    ("k8s/statefulset", "https://kubernetesjsonschema.dev/v{version}/statefulset.json"),
    ("k8s/daemonset", "https://kubernetesjsonschema.dev/v{version}/daemonset.json"),
    ("k8s/role", "https://kubernetesjsonschema.dev/v{version}/role.json"),
    ("k8s/clusterrole", "https://kubernetesjsonschema.dev/v{version}/clusterrole.json"),
    ("k8s/rolebinding", "https://kubernetesjsonschema.dev/v{version}/rolebinding.json"),
    ("k8s/clusterrolebinding", "https://kubernetesjsonschema.dev/v{version}/clusterrolebinding.json"),
    ("k8s/serviceaccount", "https://kubernetesjsonschema.dev/v{version}/serviceaccount.json"),
    ("k8s/generic", "https://kubernetesjsonschema.dev/v{version}/_definitions.json"),

    // CI/CD (using schemastore.org)
    ("gitlab-ci", "https://json.schemastore.org/gitlab-ci.json"),
    ("github-actions", "https://json.schemastore.org/github-workflow.json"),
    ("docker-compose", "https://json.schemastore.org/docker-compose.json"),

    // Monitoring
    ("prometheus", "https://json.schemastore.org/prometheus.json"),
    ("alertmanager", "https://json.schemastore.org/alertmanager.json"),

    // Configuration
    ("helm-values", "https://json.schemastore.org/chart.json"),
    ("ansible", "https://json.schemastore.org/ansible-playbook.json"),
    ("openapi", "https://json.schemastore.org/openapi-3.0.json"),
];

/// Default Kubernetes schema version
pub const DEFAULT_K8S_VERSION: &str = "1.30.0";

/// Error type for schema operations
#[derive(Debug, Clone)]
pub enum SchemaError {
    /// Schema not found in registry
    NotFound(String),
    /// Failed to fetch schema from remote
    FetchFailed(String, String),
    /// Failed to parse schema JSON
    ParseFailed(String),
    /// Schema validation failed
    ValidationFailed(String),
}

impl std::fmt::Display for SchemaError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SchemaError::NotFound(key) => write!(f, "Schema not found: {}", key),
            SchemaError::FetchFailed(url, err) => {
                write!(f, "Failed to fetch schema from {}: {}", url, err)
            }
            SchemaError::ParseFailed(err) => write!(f, "Failed to parse schema: {}", err),
            SchemaError::ValidationFailed(err) => write!(f, "Schema validation failed: {}", err),
        }
    }
}

impl std::error::Error for SchemaError {}

/// Schema registry with in-memory caching and bundled offline fallbacks.
///
/// Use [`SchemaRegistry::new`] for the default configuration (K8s 1.30, bundled
/// schemas enabled). Call [`get_schema_sync`](SchemaRegistry::get_schema_sync)
/// to retrieve a [`serde_json::Value`] schema by type key (e.g. `"k8s/deployment"`).
///
/// # Example
///
/// ```rust
/// use devops_validate::schema::SchemaRegistry;
///
/// let mut registry = SchemaRegistry::new();
///
/// // Retrieve from bundled fallback (works offline)
/// let schema = registry.get_schema_sync("k8s/deployment").unwrap();
/// assert!(schema.is_object());
///
/// // Resolve remote URL (for async fetch — registry only provides the URL)
/// let url = registry.get_schema_url("gitlab-ci").unwrap();
/// assert!(url.contains("schemastore.org"));
/// ```
pub struct SchemaRegistry {
    /// In-memory cache of compiled schemas
    cache: HashMap<String, Value>,
    /// Kubernetes version for schema URLs
    k8s_version: String,
    /// Whether to use bundled fallback schemas when offline
    use_bundled_fallback: bool,
}

impl Default for SchemaRegistry {
    fn default() -> Self {
        Self::new()
    }
}

impl SchemaRegistry {
    /// Create a schema registry with default settings.
    ///
    /// Defaults: Kubernetes version `1.30.0`, bundled fallback schemas enabled.
    pub fn new() -> Self {
        Self {
            cache: HashMap::new(),
            k8s_version: DEFAULT_K8S_VERSION.to_string(),
            use_bundled_fallback: true,
        }
    }

    /// Create a registry targeting a specific Kubernetes version.
    ///
    /// The version is used when constructing K8s schema URLs (e.g.
    /// `"1.28.0"` → `kubernetesjsonschema.dev/v1.28.0/...`).
    pub fn with_k8s_version(version: String) -> Self {
        Self {
            cache: HashMap::new(),
            k8s_version: version,
            use_bundled_fallback: true,
        }
    }

    /// Set whether to use bundled fallback schemas
    pub fn set_bundled_fallback(&mut self, enabled: bool) {
        self.use_bundled_fallback = enabled;
    }

    /// Set Kubernetes version
    pub fn set_k8s_version(&mut self, version: String) {
        self.k8s_version = version;
        // Clear cache since URLs will change
        self.cache.clear();
    }

/// Look up a schema by type key (e.g. `"k8s/deployment"`, `"gitlab-ci"`).
    ///
    /// Resolution order:
    /// 1. **In-memory cache** — instant return if previously fetched.
    /// 2. **Bundled fallback** — minimal embedded schema (works fully offline).
    ///
    /// For remote schema fetching, retrieve the URL with
    /// [`get_schema_url`](SchemaRegistry::get_schema_url), perform the HTTP
    /// request externally, then insert the result via
    /// [`cache_schema`](SchemaRegistry::cache_schema).
    ///
    /// # Errors
    ///
    /// Returns [`SchemaError::NotFound`] if `schema_type` is not in the
    /// built-in registry **and** no bundled fallback exists for it (e.g.
    /// for a custom schema key).
    ///
    /// # Example
    ///
    /// ```rust
    /// use devops_validate::schema::SchemaRegistry;
    ///
    /// let mut r = SchemaRegistry::new();
    /// let schema = r.get_schema_sync("k8s/deployment").unwrap();
    /// assert_eq!(schema["type"], "object");
    ///
    /// assert!(r.get_schema_sync("nonexistent/type").is_err());
    /// ```
    pub fn get_schema_sync(&mut self, schema_type: &str) -> Result<Value, SchemaError> {
        // 1. Check in-memory cache
        if let Some(cached) = self.cache.get(schema_type) {
            return Ok(cached.clone());
        }

        // 2. Get bundled fallback (for now, remote fetch will be async via WASM bridge)
        if self.use_bundled_fallback
            && let Some(bundled) = bundled::get_bundled_schema(schema_type) {
                self.cache.insert(schema_type.to_string(), bundled.clone());
                return Ok(bundled);
            }

        // 3. Return error if not found
        Err(SchemaError::NotFound(schema_type.to_string()))
    }

    /// Resolve the remote URL for a schema type.
    ///
    /// Returns `None` if `schema_type` is not in [`SCHEMA_URLS`].
    /// For Kubernetes schemas the URL contains the configured K8s version.
    ///
    /// # Example
    ///
    /// ```rust
    /// use devops_validate::schema::SchemaRegistry;
    ///
    /// let r = SchemaRegistry::with_k8s_version("1.28.0".to_string());
    /// let url = r.get_schema_url("k8s/deployment").unwrap();
    /// assert!(url.contains("1.28.0"));
    /// assert!(r.get_schema_url("unknown").is_none());
    /// ```
    pub fn get_schema_url(&self, schema_type: &str) -> Option<String> {
        for (key, url_template) in SCHEMA_URLS {
            if *key == schema_type {
                let url = if url_template.contains("{version}") {
                    url_template.replace("{version}", &self.k8s_version)
                } else {
                    url_template.to_string()
                };
                return Some(url);
            }
        }
        None
    }

    /// Insert a remotely-fetched schema into the in-memory cache.
    ///
    /// Call this after fetching the schema JSON from the URL returned by
    /// [`get_schema_url`](SchemaRegistry::get_schema_url).
    pub fn cache_schema(&mut self, schema_type: &str, schema: Value) {
        self.cache.insert(schema_type.to_string(), schema);
    }

    /// Evict all in-memory cached schemas.
    pub fn clear_cache(&mut self) {
        self.cache.clear();
    }

    /// Convenience wrapper around [`get_schema_sync`](SchemaRegistry::get_schema_sync)
    /// that accepts a [`YamlType`] instead of a string key.
    pub fn get_schema_for_type(&mut self, yaml_type: YamlType) -> Result<Value, SchemaError> {
        self.get_schema_sync(yaml_type.to_schema_key())
    }

    /// Return all schema type keys known to this registry.
    ///
    /// These are the valid arguments to [`get_schema_sync`](SchemaRegistry::get_schema_sync)
    /// and [`get_schema_url`](SchemaRegistry::get_schema_url).
    pub fn list_schema_types(&self) -> Vec<&'static str> {
        SCHEMA_URLS.iter().map(|(key, _)| *key).collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_get_schema_url_k8s() {
        let registry = SchemaRegistry::new();
        let url = registry.get_schema_url("k8s/deployment").unwrap();
        assert!(url.contains("kubernetesjsonschema.dev"));
        assert!(url.contains("1.30.0"));
    }

    #[test]
    fn test_get_schema_url_gitlab_ci() {
        let registry = SchemaRegistry::new();
        let url = registry.get_schema_url("gitlab-ci").unwrap();
        assert_eq!(url, "https://json.schemastore.org/gitlab-ci.json");
    }

    #[test]
    fn test_get_schema_url_unknown() {
        let registry = SchemaRegistry::new();
        let url = registry.get_schema_url("unknown/type");
        assert!(url.is_none());
    }

    #[test]
    fn test_custom_k8s_version() {
        let registry = SchemaRegistry::with_k8s_version("1.28.0".to_string());
        let url = registry.get_schema_url("k8s/service").unwrap();
        assert!(url.contains("1.28.0"));
    }

    #[test]
    fn test_list_schema_types() {
        let registry = SchemaRegistry::new();
        let types = registry.list_schema_types();
        assert!(types.contains(&"k8s/deployment"));
        assert!(types.contains(&"gitlab-ci"));
        assert!(types.contains(&"docker-compose"));
    }

    #[test]
    fn test_bundled_fallback() {
        let mut registry = SchemaRegistry::new();
        // Should return bundled schema for k8s/deployment
        let result = registry.get_schema_sync("k8s/deployment");
        assert!(result.is_ok());
    }
}