Skip to main content

devops_validate/schema/
resolver.rs

1//! YAML type detection from content
2//!
3//! Simplified type detection that prioritizes explicit markers over heuristics.
4
5use serde_json::Value;
6
7/// Supported YAML configuration types for schema-based validation.
8///
9/// Variant names are self-documenting (`K8sDeployment`, `GitLabCI`, etc.).
10/// Use [`detect_type`] to obtain a value from parsed YAML content, then
11/// convert to a schema key with [`to_schema_key`](YamlType::to_schema_key).
12#[allow(missing_docs)]
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum YamlType {
15    // Kubernetes
16    K8sDeployment,
17    K8sService,
18    K8sConfigMap,
19    K8sSecret,
20    K8sIngress,
21    K8sHPA,
22    K8sCronJob,
23    K8sJob,
24    K8sPVC,
25    K8sNetworkPolicy,
26    K8sStatefulSet,
27    K8sDaemonSet,
28    K8sRole,
29    K8sClusterRole,
30    K8sRoleBinding,
31    K8sClusterRoleBinding,
32    K8sServiceAccount,
33    K8sGeneric,
34
35    // CI/CD
36    GitLabCI,
37    GitHubActions,
38
39    // Containers
40    DockerCompose,
41
42    // Monitoring
43    Prometheus,
44    Alertmanager,
45
46    // Configuration
47    HelmValues,
48    Ansible,
49    OpenAPI,
50
51    // Fallback
52    Generic,
53}
54
55impl YamlType {
56    /// Convert to schema registry key (e.g., "k8s/deployment")
57    pub fn to_schema_key(&self) -> &'static str {
58        match self {
59            YamlType::K8sDeployment => "k8s/deployment",
60            YamlType::K8sService => "k8s/service",
61            YamlType::K8sConfigMap => "k8s/configmap",
62            YamlType::K8sSecret => "k8s/secret",
63            YamlType::K8sIngress => "k8s/ingress",
64            YamlType::K8sHPA => "k8s/horizontalpodautoscaler",
65            YamlType::K8sCronJob => "k8s/cronjob",
66            YamlType::K8sJob => "k8s/job",
67            YamlType::K8sPVC => "k8s/persistentvolumeclaim",
68            YamlType::K8sNetworkPolicy => "k8s/networkpolicy",
69            YamlType::K8sStatefulSet => "k8s/statefulset",
70            YamlType::K8sDaemonSet => "k8s/daemonset",
71            YamlType::K8sRole => "k8s/role",
72            YamlType::K8sClusterRole => "k8s/clusterrole",
73            YamlType::K8sRoleBinding => "k8s/rolebinding",
74            YamlType::K8sClusterRoleBinding => "k8s/clusterrolebinding",
75            YamlType::K8sServiceAccount => "k8s/serviceaccount",
76            YamlType::K8sGeneric => "k8s/generic",
77
78            YamlType::GitLabCI => "gitlab-ci",
79            YamlType::GitHubActions => "github-actions",
80            YamlType::DockerCompose => "docker-compose",
81            YamlType::Prometheus => "prometheus",
82            YamlType::Alertmanager => "alertmanager",
83            YamlType::HelmValues => "helm-values",
84            YamlType::Ansible => "ansible",
85            YamlType::OpenAPI => "openapi",
86
87            YamlType::Generic => "generic",
88        }
89    }
90
91    /// Check if this is a Kubernetes type
92    pub fn is_kubernetes(&self) -> bool {
93        matches!(
94            self,
95            YamlType::K8sDeployment
96                | YamlType::K8sService
97                | YamlType::K8sConfigMap
98                | YamlType::K8sSecret
99                | YamlType::K8sIngress
100                | YamlType::K8sHPA
101                | YamlType::K8sCronJob
102                | YamlType::K8sJob
103                | YamlType::K8sPVC
104                | YamlType::K8sNetworkPolicy
105                | YamlType::K8sStatefulSet
106                | YamlType::K8sDaemonSet
107                | YamlType::K8sRole
108                | YamlType::K8sClusterRole
109                | YamlType::K8sRoleBinding
110                | YamlType::K8sClusterRoleBinding
111                | YamlType::K8sServiceAccount
112                | YamlType::K8sGeneric
113        )
114    }
115
116    /// Get display name for UI
117    pub fn display_name(&self) -> &'static str {
118        match self {
119            YamlType::K8sDeployment => "Kubernetes Deployment",
120            YamlType::K8sService => "Kubernetes Service",
121            YamlType::K8sConfigMap => "Kubernetes ConfigMap",
122            YamlType::K8sSecret => "Kubernetes Secret",
123            YamlType::K8sIngress => "Kubernetes Ingress",
124            YamlType::K8sHPA => "Kubernetes HPA",
125            YamlType::K8sCronJob => "Kubernetes CronJob",
126            YamlType::K8sJob => "Kubernetes Job",
127            YamlType::K8sPVC => "Kubernetes PVC",
128            YamlType::K8sNetworkPolicy => "Kubernetes NetworkPolicy",
129            YamlType::K8sStatefulSet => "Kubernetes StatefulSet",
130            YamlType::K8sDaemonSet => "Kubernetes DaemonSet",
131            YamlType::K8sRole => "Kubernetes Role",
132            YamlType::K8sClusterRole => "Kubernetes ClusterRole",
133            YamlType::K8sRoleBinding => "Kubernetes RoleBinding",
134            YamlType::K8sClusterRoleBinding => "Kubernetes ClusterRoleBinding",
135            YamlType::K8sServiceAccount => "Kubernetes ServiceAccount",
136            YamlType::K8sGeneric => "Kubernetes Resource",
137            YamlType::GitLabCI => "GitLab CI",
138            YamlType::GitHubActions => "GitHub Actions",
139            YamlType::DockerCompose => "Docker Compose",
140            YamlType::Prometheus => "Prometheus Config",
141            YamlType::Alertmanager => "Alertmanager Config",
142            YamlType::HelmValues => "Helm Values",
143            YamlType::Ansible => "Ansible Playbook",
144            YamlType::OpenAPI => "OpenAPI Spec",
145            YamlType::Generic => "Generic YAML",
146        }
147    }
148}
149
150impl std::fmt::Display for YamlType {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        write!(f, "{}", self.display_name())
153    }
154}
155
156/// Detect YAML type from parsed content.
157///
158/// This function uses a schema-first approach:
159/// 1. Check for explicit `$schema` field
160/// 2. Check for Kubernetes `apiVersion` + `kind`
161/// 3. Check for CI/CD patterns
162/// 4. Fallback to Generic
163pub fn detect_type(data: &Value) -> YamlType {
164    let obj = match data.as_object() {
165        Some(o) => o,
166        None => {
167            // Top-level array could be Ansible playbook
168            if data.as_array().is_some() && looks_like_ansible(data) {
169                return YamlType::Ansible;
170            }
171            return YamlType::Generic;
172        }
173    };
174
175    // 1. Check for explicit $schema field
176    if let Some(schema_url) = obj.get("$schema").and_then(|s| s.as_str()) {
177        return detect_type_from_schema_url(schema_url);
178    }
179
180    // 2. Kubernetes: apiVersion + kind → direct mapping
181    if let (Some(_api_version), Some(kind)) = (
182        obj.get("apiVersion").and_then(|v| v.as_str()),
183        obj.get("kind").and_then(|v| v.as_str()),
184    ) {
185        return match kind {
186            "Deployment" => YamlType::K8sDeployment,
187            "Service" => YamlType::K8sService,
188            "ConfigMap" => YamlType::K8sConfigMap,
189            "Secret" => YamlType::K8sSecret,
190            "Ingress" => YamlType::K8sIngress,
191            "HorizontalPodAutoscaler" => YamlType::K8sHPA,
192            "CronJob" => YamlType::K8sCronJob,
193            "Job" => YamlType::K8sJob,
194            "PersistentVolumeClaim" => YamlType::K8sPVC,
195            "NetworkPolicy" => YamlType::K8sNetworkPolicy,
196            "StatefulSet" => YamlType::K8sStatefulSet,
197            "DaemonSet" => YamlType::K8sDaemonSet,
198            "Role" => YamlType::K8sRole,
199            "ClusterRole" => YamlType::K8sClusterRole,
200            "RoleBinding" => YamlType::K8sRoleBinding,
201            "ClusterRoleBinding" => YamlType::K8sClusterRoleBinding,
202            "ServiceAccount" => YamlType::K8sServiceAccount,
203            _ => YamlType::K8sGeneric,
204        };
205    }
206
207    // 3. GitLab CI: has "stages" or jobs with "script"
208    if obj.contains_key("stages") || obj.values().any(|v| v.get("script").is_some()) {
209        return YamlType::GitLabCI;
210    }
211
212    // 4. GitHub Actions: has "on" + "jobs"
213    if obj.contains_key("on") && obj.contains_key("jobs") {
214        return YamlType::GitHubActions;
215    }
216
217    // 5. Docker Compose: has "services" with sub-objects
218    if let Some(services) = obj.get("services").and_then(|s| s.as_object())
219        && !services.is_empty()
220        && services.values().any(|v| v.is_object())
221    {
222        return YamlType::DockerCompose;
223    }
224
225    // 6. Prometheus: has "scrape_configs" or "global.scrape_interval"
226    if obj.contains_key("scrape_configs")
227        || obj
228            .get("global")
229            .and_then(|g| g.get("scrape_interval"))
230            .is_some()
231    {
232        return YamlType::Prometheus;
233    }
234
235    // 7. Alertmanager: has "route" + "receivers"
236    if obj.contains_key("route") && obj.contains_key("receivers") {
237        return YamlType::Alertmanager;
238    }
239
240    // 8. Helm values.yaml: heuristic detection
241    if looks_like_helm_values(obj) {
242        return YamlType::HelmValues;
243    }
244
245    // 9. OpenAPI: has "openapi" or "swagger" key
246    if obj.contains_key("openapi") || obj.contains_key("swagger") {
247        return YamlType::OpenAPI;
248    }
249
250    YamlType::Generic
251}
252
253/// Detect type from $schema URL
254fn detect_type_from_schema_url(url: &str) -> YamlType {
255    if url.contains("kubernetesjsonschema.dev") || url.contains("kubernetes") {
256        YamlType::K8sGeneric
257    } else if url.contains("gitlab-ci") {
258        YamlType::GitLabCI
259    } else if url.contains("github-workflow") || url.contains("github-actions") {
260        YamlType::GitHubActions
261    } else if url.contains("docker-compose") {
262        YamlType::DockerCompose
263    } else if url.contains("prometheus") {
264        YamlType::Prometheus
265    } else if url.contains("alertmanager") {
266        YamlType::Alertmanager
267    } else if url.contains("openapi") || url.contains("swagger") {
268        YamlType::OpenAPI
269    } else {
270        YamlType::Generic
271    }
272}
273
274/// Check if top-level array looks like Ansible playbook
275fn looks_like_ansible(data: &Value) -> bool {
276    data.as_array()
277        .map(|arr| {
278            arr.iter().all(|item| {
279                item.get("hosts").is_some()
280                    || item.get("tasks").is_some()
281                    || item.get("roles").is_some()
282            })
283        })
284        .unwrap_or(false)
285}
286
287/// Heuristic check for Helm values.yaml
288fn looks_like_helm_values(obj: &serde_json::Map<String, Value>) -> bool {
289    // Helm values often have these common patterns
290    let has_common_helm_keys = obj.contains_key("image")
291        || obj.contains_key("replicaCount")
292        || obj.contains_key("service")
293        && obj.get("service").and_then(|s| s.get("type")).is_some();
294
295    // But shouldn't have K8s markers
296    let no_k8s_markers = !obj.contains_key("apiVersion") && !obj.contains_key("kind");
297
298    has_common_helm_keys && no_k8s_markers
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use serde_json::json;
305
306    #[test]
307    fn test_detect_k8s_deployment() {
308        let data = json!({
309            "apiVersion": "apps/v1",
310            "kind": "Deployment",
311            "metadata": { "name": "test" },
312            "spec": {}
313        });
314        assert_eq!(detect_type(&data), YamlType::K8sDeployment);
315    }
316
317    #[test]
318    fn test_detect_k8s_service() {
319        let data = json!({
320            "apiVersion": "v1",
321            "kind": "Service",
322            "metadata": { "name": "test" }
323        });
324        assert_eq!(detect_type(&data), YamlType::K8sService);
325    }
326
327    #[test]
328    fn test_detect_gitlab_ci() {
329        let data = json!({
330            "stages": ["build", "test"],
331            "build_job": { "script": ["echo hello"] }
332        });
333        assert_eq!(detect_type(&data), YamlType::GitLabCI);
334    }
335
336    #[test]
337    fn test_detect_github_actions() {
338        let data = json!({
339            "on": ["push"],
340            "jobs": { "build": {} }
341        });
342        assert_eq!(detect_type(&data), YamlType::GitHubActions);
343    }
344
345    #[test]
346    fn test_detect_docker_compose() {
347        let data = json!({
348            "services": {
349                "web": { "image": "nginx" }
350            }
351        });
352        assert_eq!(detect_type(&data), YamlType::DockerCompose);
353    }
354
355    #[test]
356    fn test_detect_prometheus() {
357        let data = json!({
358            "global": { "scrape_interval": "15s" },
359            "scrape_configs": []
360        });
361        assert_eq!(detect_type(&data), YamlType::Prometheus);
362    }
363
364    #[test]
365    fn test_detect_alertmanager() {
366        let data = json!({
367            "route": { "receiver": "default" },
368            "receivers": [{ "name": "default" }]
369        });
370        assert_eq!(detect_type(&data), YamlType::Alertmanager);
371    }
372
373    #[test]
374    fn test_detect_openapi() {
375        let data = json!({
376            "openapi": "3.0.0",
377            "info": { "title": "API", "version": "1.0" }
378        });
379        assert_eq!(detect_type(&data), YamlType::OpenAPI);
380    }
381
382    #[test]
383    fn test_schema_key_conversion() {
384        assert_eq!(YamlType::K8sDeployment.to_schema_key(), "k8s/deployment");
385        assert_eq!(YamlType::GitLabCI.to_schema_key(), "gitlab-ci");
386        assert_eq!(YamlType::DockerCompose.to_schema_key(), "docker-compose");
387    }
388}