1use devops_models::models::ansible::AnsiblePlay;
22use devops_models::models::docker_compose::DockerCompose;
23use devops_models::models::github_actions::GitHubActions;
24use devops_models::models::gitlab::GitLabCI;
25use devops_models::models::helm::HelmValues;
26use devops_models::models::k8s::*;
27use devops_models::models::k8s_networking::{K8sIngress, K8sNetworkPolicy};
28use devops_models::models::k8s_rbac::{K8sRole, K8sRoleBinding, K8sServiceAccount};
29use devops_models::models::k8s_storage::K8sPVC;
30use devops_models::models::k8s_workloads::{K8sCronJob, K8sDaemonSet, K8sHPA, K8sJob, K8sStatefulSet};
31use devops_models::models::prometheus::{AlertmanagerConfig, PrometheusConfig};
32use devops_models::models::validation::{
33 ConfigValidator, Diagnostic, ValidationResult, YamlType,
34};
35
36pub fn parse_yaml(content: &str) -> Result<serde_json::Value, String> {
60 let value: serde_json::Value =
61 serde_yaml::from_str(content).map_err(|e| format!("YAML parse error: {e}"))?;
62 if !value.is_object() && !value.is_array() {
63 return Err(format!(
64 "YAML must be a mapping or array, got: {}",
65 value_type_name(&value)
66 ));
67 }
68 Ok(value)
69}
70
71pub fn detect_yaml_type(data: &serde_json::Value) -> YamlType {
88 if data.is_array() && AnsiblePlay::looks_like_playbook(data) {
90 return YamlType::Ansible;
91 }
92
93 let obj = match data.as_object() {
94 Some(o) => o,
95 None => return YamlType::Generic,
96 };
97
98 if obj.contains_key("apiVersion") && obj.contains_key("kind") {
100 let kind = obj.get("kind").and_then(|v| v.as_str()).unwrap_or("");
101 return match kind {
102 "Deployment" => YamlType::K8sDeployment,
103 "Service" => YamlType::K8sService,
104 "ConfigMap" => YamlType::K8sConfigMap,
105 "Secret" => YamlType::K8sSecret,
106 "Ingress" => YamlType::K8sIngress,
107 "HorizontalPodAutoscaler" => YamlType::K8sHPA,
108 "CronJob" => YamlType::K8sCronJob,
109 "Job" => YamlType::K8sJob,
110 "PersistentVolumeClaim" => YamlType::K8sPVC,
111 "NetworkPolicy" => YamlType::K8sNetworkPolicy,
112 "StatefulSet" => YamlType::K8sStatefulSet,
113 "DaemonSet" => YamlType::K8sDaemonSet,
114 "Role" => YamlType::K8sRole,
115 "ClusterRole" => YamlType::K8sClusterRole,
116 "RoleBinding" => YamlType::K8sRoleBinding,
117 "ClusterRoleBinding" => YamlType::K8sClusterRoleBinding,
118 "ServiceAccount" => YamlType::K8sServiceAccount,
119 _ => YamlType::K8sGeneric,
120 };
121 }
122
123 if obj.contains_key("services")
125 && let Some(svcs) = obj.get("services").and_then(|v| v.as_object()) {
126 let looks_like_compose = svcs.values().any(|v| v.is_object());
129 if looks_like_compose {
130 return YamlType::DockerCompose;
131 }
132 }
133
134 if obj.contains_key("on") && obj.contains_key("jobs") {
136 return YamlType::GitHubActions;
137 }
138
139 if obj.contains_key("stages") || obj.values().any(|v| v.get("script").is_some()) {
141 return YamlType::GitLabCI;
142 }
143
144 if obj.contains_key("scrape_configs")
146 || obj
147 .get("global")
148 .and_then(|g| g.get("scrape_interval"))
149 .is_some()
150 {
151 return YamlType::Prometheus;
152 }
153
154 if obj.contains_key("route") && obj.contains_key("receivers") {
156 return YamlType::Alertmanager;
157 }
158
159 if HelmValues::looks_like_helm(data) {
161 return YamlType::HelmValues;
162 }
163
164 if obj.contains_key("openapi") || obj.contains_key("swagger") {
166 return YamlType::OpenAPI;
167 }
168
169 YamlType::Generic
170}
171
172pub fn validate_k8s_manifest(content: &str) -> ValidationResult {
211 let data = match parse_yaml(content) {
212 Ok(d) => d,
213 Err(e) => {
214 return ValidationResult {
215 valid: false,
216 errors: vec![e],
217 warnings: vec![],
218 diagnostics: vec![],
219 hints: vec![],
220 yaml_type: None,
221 }
222 }
223 };
224
225 let kind = data
226 .get("kind")
227 .and_then(|v| v.as_str())
228 .unwrap_or("")
229 .to_string();
230
231 let extra_warnings = collect_k8s_warnings(&data);
233
234 match kind.as_str() {
235 "Deployment" => match serde_json::from_value::<K8sDeployment>(data) {
236 Ok(dep) => {
237 let mut warnings = extra_warnings;
238 warnings.extend(dep.validate());
239 ValidationResult {
240 valid: true,
241 errors: vec![],
242 diagnostics: vec![],
243 warnings,
244 hints: vec![],
245 yaml_type: Some(YamlType::K8sDeployment),
246 }
247 }
248 Err(e) => ValidationResult {
249 valid: false,
250 errors: format_serde_errors(&e),
251 warnings: vec![],
252 diagnostics: vec![],
253 hints: vec![],
254 yaml_type: Some(YamlType::K8sDeployment),
255 },
256 },
257 "Service" => match serde_json::from_value::<K8sService>(data) {
258 Ok(svc) => {
259 let mut warnings = extra_warnings;
260 warnings.extend(validate_service_semantics(&svc));
261 ValidationResult {
262 valid: true,
263 errors: vec![],
264 diagnostics: vec![],
265 warnings,
266 hints: vec![],
267 yaml_type: Some(YamlType::K8sService),
268 }
269 }
270 Err(e) => ValidationResult {
271 valid: false,
272 errors: format_serde_errors(&e),
273 diagnostics: vec![],
274 warnings: vec![],
275 hints: vec![],
276 yaml_type: Some(YamlType::K8sService),
277 },
278 },
279 "ConfigMap" => match serde_json::from_value::<K8sConfigMap>(data) {
280 Ok(cm) => {
281 let mut warnings = extra_warnings;
282 if cm.data.is_empty() && cm.binary_data.is_none() {
283 warnings.push("ConfigMap has no data and no binaryData".to_string());
284 }
285 ValidationResult {
286 valid: true,
287 errors: vec![],
288 diagnostics: vec![],
289 warnings,
290 hints: vec![],
291 yaml_type: Some(YamlType::K8sConfigMap),
292 }
293 }
294 Err(e) => ValidationResult {
295 valid: false,
296 errors: format_serde_errors(&e),
297 diagnostics: vec![],
298 warnings: vec![],
299 hints: vec![],
300 yaml_type: Some(YamlType::K8sConfigMap),
301 },
302 },
303 "Secret" => match serde_json::from_value::<K8sSecret>(data) {
304 Ok(sec) => {
305 let mut warnings = extra_warnings;
306 warnings.extend(validate_secret_semantics(&sec));
307 ValidationResult {
308 valid: true,
309 errors: vec![],
310 diagnostics: vec![],
311 warnings,
312 hints: vec![],
313 yaml_type: Some(YamlType::K8sSecret),
314 }
315 }
316 Err(e) => ValidationResult {
317 valid: false,
318 errors: format_serde_errors(&e),
319 diagnostics: vec![],
320 warnings: vec![],
321 hints: vec![],
322 yaml_type: Some(YamlType::K8sSecret),
323 },
324 },
325 "Ingress" => validate_k8s_with_trait::<K8sIngress>(data, YamlType::K8sIngress),
327 "HorizontalPodAutoscaler" => validate_k8s_with_trait::<K8sHPA>(data, YamlType::K8sHPA),
328 "CronJob" => validate_k8s_with_trait::<K8sCronJob>(data, YamlType::K8sCronJob),
329 "Job" => validate_k8s_with_trait::<K8sJob>(data, YamlType::K8sJob),
330 "PersistentVolumeClaim" => validate_k8s_with_trait::<K8sPVC>(data, YamlType::K8sPVC),
331 "NetworkPolicy" => {
332 validate_k8s_with_trait::<K8sNetworkPolicy>(data, YamlType::K8sNetworkPolicy)
333 }
334 "StatefulSet" => {
335 validate_k8s_with_trait::<K8sStatefulSet>(data, YamlType::K8sStatefulSet)
336 }
337 "DaemonSet" => validate_k8s_with_trait::<K8sDaemonSet>(data, YamlType::K8sDaemonSet),
338 "Role" | "ClusterRole" => validate_k8s_with_trait::<K8sRole>(data, YamlType::K8sRole),
339 "RoleBinding" | "ClusterRoleBinding" => {
340 validate_k8s_with_trait::<K8sRoleBinding>(data, YamlType::K8sRoleBinding)
341 }
342 "ServiceAccount" => {
343 validate_k8s_with_trait::<K8sServiceAccount>(data, YamlType::K8sServiceAccount)
344 }
345 _ => validate_k8s_generic(data, &kind),
347 }
348}
349
350fn validate_k8s_with_trait<T>(data: serde_json::Value, yaml_type: YamlType) -> ValidationResult
352where
353 T: serde::de::DeserializeOwned + ConfigValidator,
354{
355 match serde_json::from_value::<T>(data) {
356 Ok(resource) => resource.validate(),
357 Err(e) => ValidationResult {
358 valid: false,
359 errors: format_serde_errors(&e),
360 warnings: vec![],
361 diagnostics: vec![],
362 hints: vec![],
363 yaml_type: Some(yaml_type),
364 },
365 }
366}
367
368fn validate_k8s_generic(data: serde_json::Value, kind: &str) -> ValidationResult {
370 let mut warnings = vec![format!(
371 "Kind '{}' has no specific validator — only basic structure checked",
372 kind
373 )];
374 let obj = data.as_object();
375 if let Some(o) = obj {
376 if !o.contains_key("metadata") {
377 warnings
378 .push(format!("Kind '{}' is missing 'metadata' — unusual for a K8s resource", kind));
379 }
380 if !o.contains_key("spec") && !o.contains_key("data") && !o.contains_key("rules") {
381 warnings.push(format!("Kind '{}' has no 'spec', 'data', or 'rules' field", kind));
382 }
383 }
384 ValidationResult {
385 valid: true,
386 errors: vec![],
387 diagnostics: vec![],
388 warnings,
389 hints: vec![],
390 yaml_type: Some(YamlType::K8sGeneric),
391 }
392}
393
394pub fn validate_gitlab_ci(content: &str) -> ValidationResult {
396 let data = match parse_yaml(content) {
397 Ok(d) => d,
398 Err(e) => {
399 return ValidationResult {
400 valid: false,
401 errors: vec![e],
402 warnings: vec![],
403 diagnostics: vec![],
404 hints: vec![],
405 yaml_type: Some(YamlType::GitLabCI),
406 }
407 }
408 };
409
410 match GitLabCI::from_value(&data) {
411 Ok(ci) => {
412 let warnings = ci.validate();
413 ValidationResult {
414 valid: true,
415 errors: vec![],
416 warnings,
417 diagnostics: vec![],
418 hints: vec![],
419 yaml_type: Some(YamlType::GitLabCI),
420 }
421 }
422 Err(e) => ValidationResult {
423 valid: false,
424 errors: vec![e],
425 warnings: vec![],
426 diagnostics: vec![],
427 hints: vec![],
428 yaml_type: Some(YamlType::GitLabCI),
429 },
430 }
431}
432
433pub fn validate_docker_compose(content: &str) -> ValidationResult {
435 let data = match parse_yaml(content) {
436 Ok(d) => d,
437 Err(e) => {
438 return ValidationResult {
439 valid: false,
440 errors: vec![e],
441 warnings: vec![],
442 diagnostics: vec![],
443 hints: vec![],
444 yaml_type: Some(YamlType::DockerCompose),
445 }
446 }
447 };
448
449 match DockerCompose::from_value(data) {
450 Ok(compose) => compose.validate(),
451 Err(e) => ValidationResult {
452 valid: false,
453 errors: vec![e],
454 warnings: vec![],
455 diagnostics: vec![],
456 hints: vec![],
457 yaml_type: Some(YamlType::DockerCompose),
458 },
459 }
460}
461
462pub fn validate_github_actions(content: &str) -> ValidationResult {
464 let data = match parse_yaml(content) {
465 Ok(d) => d,
466 Err(e) => {
467 return ValidationResult {
468 valid: false,
469 errors: vec![e],
470 warnings: vec![],
471 diagnostics: vec![],
472 hints: vec![],
473 yaml_type: Some(YamlType::GitHubActions),
474 }
475 }
476 };
477
478 match GitHubActions::from_value(&data) {
479 Ok(actions) => actions.validate(),
480 Err(e) => ValidationResult {
481 valid: false,
482 errors: vec![e],
483 warnings: vec![],
484 diagnostics: vec![],
485 hints: vec![],
486 yaml_type: Some(YamlType::GitHubActions),
487 },
488 }
489}
490
491pub fn validate_prometheus(content: &str) -> ValidationResult {
493 let data = match parse_yaml(content) {
494 Ok(d) => d,
495 Err(e) => {
496 return ValidationResult {
497 valid: false,
498 errors: vec![e],
499 warnings: vec![],
500 diagnostics: vec![],
501 hints: vec![],
502 yaml_type: Some(YamlType::Prometheus),
503 }
504 }
505 };
506
507 match PrometheusConfig::from_value(data) {
508 Ok(config) => config.validate(),
509 Err(e) => ValidationResult {
510 valid: false,
511 errors: vec![e],
512 warnings: vec![],
513 diagnostics: vec![],
514 hints: vec![],
515 yaml_type: Some(YamlType::Prometheus),
516 },
517 }
518}
519
520pub fn validate_alertmanager(content: &str) -> ValidationResult {
522 let data = match parse_yaml(content) {
523 Ok(d) => d,
524 Err(e) => {
525 return ValidationResult {
526 valid: false,
527 errors: vec![e],
528 warnings: vec![],
529 diagnostics: vec![],
530 hints: vec![],
531 yaml_type: Some(YamlType::Alertmanager),
532 }
533 }
534 };
535
536 match AlertmanagerConfig::from_value(data) {
537 Ok(config) => config.validate(),
538 Err(e) => ValidationResult {
539 valid: false,
540 errors: vec![e],
541 warnings: vec![],
542 diagnostics: vec![],
543 hints: vec![],
544 yaml_type: Some(YamlType::Alertmanager),
545 },
546 }
547}
548
549pub fn validate_helm_values(content: &str) -> ValidationResult {
551 let data = match parse_yaml(content) {
552 Ok(d) => d,
553 Err(e) => {
554 return ValidationResult {
555 valid: false,
556 errors: vec![e],
557 warnings: vec![],
558 diagnostics: vec![],
559 hints: vec![],
560 yaml_type: Some(YamlType::HelmValues),
561 }
562 }
563 };
564
565 match HelmValues::from_value(&data) {
566 Ok(values) => values.validate(),
567 Err(e) => ValidationResult {
568 valid: false,
569 errors: vec![e],
570 warnings: vec![],
571 diagnostics: vec![],
572 hints: vec![],
573 yaml_type: Some(YamlType::HelmValues),
574 },
575 }
576}
577
578pub fn validate_ansible(content: &str) -> ValidationResult {
580 let data = match parse_yaml(content) {
581 Ok(d) => d,
582 Err(e) => {
583 return ValidationResult {
584 valid: false,
585 errors: vec![e],
586 warnings: vec![],
587 diagnostics: vec![],
588 hints: vec![],
589 yaml_type: Some(YamlType::Ansible),
590 }
591 }
592 };
593
594 let playbook: Vec<AnsiblePlay> = match serde_json::from_value(data) {
596 Ok(p) => p,
597 Err(e) => {
598 return ValidationResult {
599 valid: false,
600 errors: vec![format!("Failed to parse Ansible playbook: {e}")],
601 warnings: vec![],
602 diagnostics: vec![],
603 hints: vec![],
604 yaml_type: Some(YamlType::Ansible),
605 }
606 }
607 };
608
609 playbook.validate()
610}
611
612pub fn validate_auto(content: &str) -> ValidationResult {
655 let documents = split_yaml_documents(content);
657 if documents.len() > 1 {
658 return validate_multi_document(&documents);
659 }
660
661 let data = match parse_yaml(content) {
662 Ok(d) => d,
663 Err(e) => {
664 return ValidationResult {
665 valid: false,
666 errors: vec![e],
667 warnings: vec![],
668 diagnostics: vec![],
669 hints: vec![],
670 yaml_type: None,
671 }
672 }
673 };
674
675 let yaml_type = detect_yaml_type(&data);
676 match yaml_type {
677 YamlType::K8sDeployment
678 | YamlType::K8sService
679 | YamlType::K8sConfigMap
680 | YamlType::K8sSecret
681 | YamlType::K8sIngress
682 | YamlType::K8sHPA
683 | YamlType::K8sCronJob
684 | YamlType::K8sJob
685 | YamlType::K8sPVC
686 | YamlType::K8sNetworkPolicy
687 | YamlType::K8sStatefulSet
688 | YamlType::K8sDaemonSet
689 | YamlType::K8sRole
690 | YamlType::K8sClusterRole
691 | YamlType::K8sRoleBinding
692 | YamlType::K8sClusterRoleBinding
693 | YamlType::K8sServiceAccount
694 | YamlType::K8sGeneric => validate_k8s_manifest(content),
695 YamlType::GitLabCI => validate_gitlab_ci(content),
696 YamlType::DockerCompose => validate_docker_compose(content),
697 YamlType::GitHubActions => validate_github_actions(content),
698 YamlType::Prometheus => validate_prometheus(content),
699 YamlType::Alertmanager => validate_alertmanager(content),
700 YamlType::HelmValues => validate_helm_values(content),
701 YamlType::Ansible => validate_ansible(content),
702 YamlType::OpenAPI => ValidationResult {
703 valid: true,
704 errors: vec![],
705 warnings: vec!["OpenAPI validation not yet implemented — parsed OK".to_string()],
706 diagnostics: vec![],
707 hints: vec![],
708 yaml_type: Some(YamlType::OpenAPI),
709 },
710 YamlType::Generic => ValidationResult {
711 valid: true,
712 errors: vec![],
713 warnings: vec!["Generic YAML — no schema validation applied".to_string()],
714 diagnostics: vec![],
715 hints: vec![],
716 yaml_type: Some(YamlType::Generic),
717 },
718 }
719}
720
721fn split_yaml_documents(content: &str) -> Vec<String> {
727 let mut documents = Vec::new();
728 let mut current = String::new();
729
730 for line in content.lines() {
731 if line.trim() == "---" && !current.trim().is_empty() {
732 documents.push(current.clone());
733 current.clear();
734 } else if line.trim() != "---" {
735 current.push_str(line);
736 current.push('\n');
737 }
738 }
739 if !current.trim().is_empty() {
740 documents.push(current);
741 }
742 documents
743}
744
745fn validate_multi_document(documents: &[String]) -> ValidationResult {
747 let mut all_errors = Vec::new();
748 let mut all_warnings = Vec::new();
749 let mut all_diagnostics = Vec::new();
750 let mut all_valid = true;
751 let mut types_seen = Vec::new();
752
753 for (i, doc) in documents.iter().enumerate() {
754 let prefix = format!("[doc {}] ", i + 1);
755 let result = validate_auto(doc.trim());
756
757 if !result.valid {
758 all_valid = false;
759 }
760 for e in &result.errors {
761 all_errors.push(format!("{}{}", prefix, e));
762 }
763 for w in &result.warnings {
764 all_warnings.push(format!("{}{}", prefix, w));
765 }
766 for d in &result.diagnostics {
767 all_diagnostics.push(Diagnostic {
768 severity: d.severity.clone(),
769 message: format!("{}{}", prefix, d.message),
770 path: d.path.as_ref().map(|p| format!("{}{}", prefix, p)),
771 });
772 }
773 if let Some(t) = &result.yaml_type {
774 types_seen.push(format!("{}", t));
775 }
776 }
777
778 let type_summary = if types_seen.is_empty() {
779 "Generic".to_string()
780 } else {
781 types_seen.join(", ")
782 };
783
784 all_warnings.insert(
785 0,
786 format!(
787 "Multi-document YAML: {} documents detected ({})",
788 documents.len(),
789 type_summary
790 ),
791 );
792
793 ValidationResult {
794 valid: all_valid,
795 errors: all_errors,
796 warnings: all_warnings,
797 diagnostics: all_diagnostics,
798 hints: vec![],
799 yaml_type: Some(YamlType::Generic), }
801}
802
803fn collect_k8s_warnings(data: &serde_json::Value) -> Vec<String> {
809 let mut warnings = Vec::new();
810 let kind = data.get("kind").and_then(|v| v.as_str()).unwrap_or("");
811
812 if kind == "Deployment" {
813 let spec = data.get("spec");
814 let replicas = spec
815 .and_then(|s| s.get("replicas"))
816 .and_then(|r| r.as_u64())
817 .unwrap_or(1);
818
819 if replicas == 1 {
820 let ns = data
821 .get("metadata")
822 .and_then(|m| m.get("namespace"))
823 .and_then(|n| n.as_str())
824 .unwrap_or("default");
825 warnings.push(format!(
826 "replicas=1 in namespace '{}' — consider >=2 for high availability",
827 ns
828 ));
829 }
830
831 if let Some(containers) = spec
832 .and_then(|s| s.get("template"))
833 .and_then(|t| t.get("spec"))
834 .and_then(|s| s.get("containers"))
835 .and_then(|c| c.as_array())
836 {
837 for c in containers {
838 let name = c.get("name").and_then(|n| n.as_str()).unwrap_or("?");
839
840 let has_limits = c
842 .get("resources")
843 .and_then(|r| r.get("limits"))
844 .is_some();
845 if !has_limits {
846 warnings.push(format!(
847 "Container '{}' has no resource limits — may cause OOM kills",
848 name
849 ));
850 }
851
852 let has_liveness = c.get("livenessProbe").is_some();
854 let has_readiness = c.get("readinessProbe").is_some();
855 if !has_liveness {
856 warnings.push(format!(
857 "Container '{}' has no livenessProbe — Kubernetes won't detect hangs",
858 name
859 ));
860 }
861 if !has_readiness {
862 warnings.push(format!(
863 "Container '{}' has no readinessProbe — traffic may be sent to unready pods",
864 name
865 ));
866 }
867
868 if let Some(image) = c.get("image").and_then(|i| i.as_str())
870 && (image.ends_with(":latest") || !image.contains(':')) {
871 warnings.push(format!(
872 "Container '{}': image '{}' uses ':latest' or no tag — pin a specific version for reproducibility",
873 name, image
874 ));
875 }
876
877 if let Some(policy) = c.get("imagePullPolicy").and_then(|p| p.as_str())
879 && policy == "Never" {
880 warnings.push(format!(
881 "Container '{}': imagePullPolicy=Never — image must be pre-loaded on every node",
882 name
883 ));
884 }
885 }
886 }
887 }
888
889 warnings
890}
891
892fn validate_service_semantics(svc: &K8sService) -> Vec<String> {
894 let mut warnings = Vec::new();
895 if let Some(svc_type) = &svc.spec.service_type {
896 if svc_type == "LoadBalancer" {
897 warnings.push("type=LoadBalancer creates a cloud load balancer — ensure this is intentional (cost implications)".to_string());
898 }
899 if svc_type == "NodePort" {
900 for port in &svc.spec.ports {
901 if port.port > 32767 {
902 warnings.push(format!(
903 "Port {} exceeds default NodePort range (30000-32767)",
904 port.port
905 ));
906 }
907 }
908 }
909 }
910 if svc.spec.selector.is_empty() {
911 warnings.push("Service has an empty selector — will match no pods".to_string());
912 }
913 warnings
914}
915
916fn validate_secret_semantics(sec: &K8sSecret) -> Vec<String> {
918 let mut warnings = Vec::new();
919 if sec.data.is_empty() && sec.string_data.is_none() {
920 warnings.push("Secret has no data and no stringData".to_string());
921 }
922 for (key, val) in &sec.data {
924 if base64_decode_check(val).is_err() {
925 warnings.push(format!(
926 "Secret key '{}': value does not appear to be valid base64",
927 key
928 ));
929 }
930 }
931 warnings
932}
933
934fn base64_decode_check(s: &str) -> Result<(), ()> {
936 let stripped = s.trim();
938 if stripped.is_empty() {
939 return Ok(());
940 }
941 let base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r ";
942 if stripped.chars().all(|c| base64_chars.contains(c)) {
943 Ok(())
944 } else {
945 Err(())
946 }
947}
948
949fn value_type_name(v: &serde_json::Value) -> &'static str {
954 match v {
955 serde_json::Value::Null => "null",
956 serde_json::Value::Bool(_) => "boolean",
957 serde_json::Value::Number(_) => "number",
958 serde_json::Value::String(_) => "string",
959 serde_json::Value::Array(_) => "array",
960 serde_json::Value::Object(_) => "object",
961 }
962}
963
964fn format_serde_errors(e: &serde_json::Error) -> Vec<String> {
965 vec![e.to_string()]
966}
967
968#[cfg(test)]
973mod tests {
974 use super::*;
975
976 #[test]
979 fn detect_k8s_deployment() {
980 let yaml = r#"apiVersion: apps/v1
981kind: Deployment
982metadata:
983 name: test
984spec:
985 replicas: 1
986 selector:
987 matchLabels:
988 app: test
989 template:
990 metadata:
991 labels:
992 app: test
993 spec:
994 containers:
995 - name: app
996 image: nginx:1.25"#;
997 let data = parse_yaml(yaml).unwrap();
998 assert_eq!(detect_yaml_type(&data), YamlType::K8sDeployment);
999 }
1000
1001 #[test]
1002 fn detect_k8s_ingress() {
1003 let yaml = r#"apiVersion: networking.k8s.io/v1
1004kind: Ingress
1005metadata:
1006 name: test
1007spec:
1008 rules: []"#;
1009 let data = parse_yaml(yaml).unwrap();
1010 assert_eq!(detect_yaml_type(&data), YamlType::K8sIngress);
1011 }
1012
1013 #[test]
1014 fn detect_k8s_generic_unknown_kind() {
1015 let yaml = r#"apiVersion: custom.io/v1
1016kind: MyCustomResource
1017metadata:
1018 name: test
1019spec:
1020 foo: bar"#;
1021 let data = parse_yaml(yaml).unwrap();
1022 assert_eq!(detect_yaml_type(&data), YamlType::K8sGeneric);
1023 }
1024
1025 #[test]
1026 fn detect_docker_compose() {
1027 let yaml = r#"services:
1028 web:
1029 image: nginx
1030 ports:
1031 - "8080:80"
1032 db:
1033 image: postgres:15"#;
1034 let data = parse_yaml(yaml).unwrap();
1035 assert_eq!(detect_yaml_type(&data), YamlType::DockerCompose);
1036 }
1037
1038 #[test]
1039 fn detect_github_actions() {
1040 let yaml = r#"name: CI
1041on: [push]
1042jobs:
1043 build:
1044 runs-on: ubuntu-latest
1045 steps:
1046 - uses: actions/checkout@v4"#;
1047 let data = parse_yaml(yaml).unwrap();
1048 assert_eq!(detect_yaml_type(&data), YamlType::GitHubActions);
1049 }
1050
1051 #[test]
1052 fn detect_gitlab_ci() {
1053 let yaml = r#"stages:
1054 - build
1055 - test
1056build_job:
1057 stage: build
1058 script:
1059 - echo hello"#;
1060 let data = parse_yaml(yaml).unwrap();
1061 assert_eq!(detect_yaml_type(&data), YamlType::GitLabCI);
1062 }
1063
1064 #[test]
1065 fn detect_prometheus() {
1066 let yaml = r#"global:
1067 scrape_interval: 15s
1068scrape_configs:
1069 - job_name: prometheus
1070 static_configs:
1071 - targets: ['localhost:9090']"#;
1072 let data = parse_yaml(yaml).unwrap();
1073 assert_eq!(detect_yaml_type(&data), YamlType::Prometheus);
1074 }
1075
1076 #[test]
1077 fn detect_alertmanager() {
1078 let yaml = r#"route:
1079 receiver: default
1080receivers:
1081 - name: default"#;
1082 let data = parse_yaml(yaml).unwrap();
1083 assert_eq!(detect_yaml_type(&data), YamlType::Alertmanager);
1084 }
1085
1086 #[test]
1089 fn validate_valid_deployment() {
1090 let yaml = r#"apiVersion: apps/v1
1091kind: Deployment
1092metadata:
1093 name: myapp
1094 labels:
1095 app: myapp
1096spec:
1097 replicas: 3
1098 selector:
1099 matchLabels:
1100 app: myapp
1101 template:
1102 metadata:
1103 labels:
1104 app: myapp
1105 spec:
1106 containers:
1107 - name: app
1108 image: myapp:v1.2.3
1109 ports:
1110 - containerPort: 8080
1111 resources:
1112 limits:
1113 memory: "128Mi"
1114 cpu: "500m"
1115 requests:
1116 memory: "64Mi"
1117 cpu: "250m"
1118 livenessProbe:
1119 httpGet:
1120 path: /healthz
1121 port: 8080
1122 readinessProbe:
1123 httpGet:
1124 path: /ready
1125 port: 8080"#;
1126 let result = validate_auto(yaml);
1127 assert!(result.valid, "Expected valid, got errors: {:?}", result.errors);
1128 }
1129
1130 #[test]
1131 fn validate_hpa_min_gt_max() {
1132 let yaml = r#"apiVersion: autoscaling/v2
1133kind: HorizontalPodAutoscaler
1134metadata:
1135 name: test
1136spec:
1137 scaleTargetRef:
1138 apiVersion: apps/v1
1139 kind: Deployment
1140 name: test
1141 minReplicas: 10
1142 maxReplicas: 5"#;
1143 let result = validate_auto(yaml);
1144 assert!(!result.valid);
1145 assert!(result.errors.iter().any(|e| e.contains("minReplicas")));
1146 }
1147
1148 #[test]
1149 fn validate_cronjob_invalid_schedule() {
1150 let yaml = r#"apiVersion: batch/v1
1151kind: CronJob
1152metadata:
1153 name: test
1154spec:
1155 schedule: "not a cron"
1156 jobTemplate:
1157 spec:
1158 template:
1159 metadata:
1160 labels:
1161 app: test
1162 spec:
1163 containers:
1164 - name: job
1165 image: busybox
1166 restartPolicy: OnFailure"#;
1167 let result = validate_auto(yaml);
1168 assert!(!result.valid);
1169 assert!(result.errors.iter().any(|e| e.contains("cron schedule")));
1170 }
1171
1172 #[test]
1173 fn validate_docker_compose_no_image() {
1174 let yaml = r#"services:
1175 web:
1176 ports:
1177 - "8080:80""#;
1178 let result = validate_auto(yaml);
1179 assert!(!result.valid);
1180 assert!(result.errors.iter().any(|e| e.contains("image") || e.contains("build")));
1181 }
1182
1183 #[test]
1184 fn validate_github_actions_no_on() {
1185 let yaml = r#"name: CI
1186jobs:
1187 build:
1188 runs-on: ubuntu-latest
1189 steps:
1190 - run: echo hello"#;
1191 let result = validate_github_actions(yaml);
1193 assert!(!result.valid);
1194 assert!(result.errors.iter().any(|e| e.contains("'on'")));
1195 }
1196
1197 #[test]
1198 fn validate_prometheus_duplicate_jobs() {
1199 let yaml = r#"scrape_configs:
1200 - job_name: myapp
1201 static_configs:
1202 - targets: ['localhost:9090']
1203 - job_name: myapp
1204 static_configs:
1205 - targets: ['localhost:8080']"#;
1206 let result = validate_prometheus(yaml);
1207 assert!(!result.valid);
1208 assert!(result.errors.iter().any(|e| e.contains("Duplicate job_name")));
1209 }
1210
1211 #[test]
1212 fn validate_alertmanager_missing_receiver() {
1213 let yaml = r#"route:
1214 receiver: missing
1215receivers:
1216 - name: default"#;
1217 let result = validate_alertmanager(yaml);
1218 assert!(!result.valid);
1219 assert!(result.errors.iter().any(|e| e.contains("not defined")));
1220 }
1221
1222 #[test]
1223 fn validate_k8s_generic_unknown_kind() {
1224 let yaml = r#"apiVersion: custom.io/v1
1225kind: MyWidget
1226metadata:
1227 name: test
1228spec:
1229 replicas: 1"#;
1230 let result = validate_auto(yaml);
1231 assert!(result.valid);
1232 assert!(result.warnings.iter().any(|w| w.contains("no specific validator")));
1233 }
1234
1235 #[test]
1238 fn validate_multi_document_yaml() {
1239 let yaml = r#"apiVersion: v1
1240kind: ConfigMap
1241metadata:
1242 name: test
1243data:
1244 key: value
1245---
1246apiVersion: v1
1247kind: Service
1248metadata:
1249 name: test
1250spec:
1251 selector:
1252 app: test
1253 ports:
1254 - port: 80"#;
1255 let result = validate_auto(yaml);
1256 assert!(result.valid);
1257 assert!(result.warnings.iter().any(|w| w.contains("Multi-document")));
1258 }
1259
1260 #[test]
1261 fn split_documents() {
1262 let yaml = "a: 1\n---\nb: 2\n---\nc: 3";
1263 let docs = split_yaml_documents(yaml);
1264 assert_eq!(docs.len(), 3);
1265 }
1266
1267 #[test]
1270 fn validate_secret_invalid_base64() {
1271 let yaml = r#"apiVersion: v1
1272kind: Secret
1273metadata:
1274 name: test
1275data:
1276 password: "not-valid-base64!@#""#;
1277 let result = validate_auto(yaml);
1278 assert!(result.valid); assert!(result.warnings.iter().any(|w| w.contains("base64")));
1280 }
1281}