devops-models 0.1.0

Typed serde models for DevOps configuration formats: Kubernetes, Docker Compose, GitLab CI, GitHub Actions, Prometheus, Alertmanager, Helm, Ansible, and OpenAPI.
Documentation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};

/// Ansible playbook - a list of plays
pub type AnsiblePlaybook = Vec<AnsiblePlay>;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnsiblePlay {
    pub name: Option<String>,
    pub hosts: String,
    #[serde(default, rename = "become")]
    pub become_enabled: Option<bool>,
    #[serde(default, rename = "become_user")]
    pub become_user: Option<String>,
    #[serde(default, rename = "become_method")]
    pub become_method: Option<String>,
    #[serde(default)]
    pub gather_facts: Option<bool>,
    #[serde(default)]
    pub vars: Option<HashMap<String, serde_json::Value>>,
    #[serde(default, rename = "vars_files")]
    pub vars_files: Option<Vec<String>>,
    #[serde(default)]
    pub pre_tasks: Option<Vec<AnsibleTask>>,
    #[serde(default)]
    pub tasks: Vec<AnsibleTask>,
    #[serde(default)]
    pub post_tasks: Option<Vec<AnsibleTask>>,
    #[serde(default)]
    pub handlers: Option<Vec<AnsibleTask>>,
    #[serde(default)]
    pub roles: Option<Vec<serde_json::Value>>,
    #[serde(default)]
    pub tags: Option<Vec<String>>,
    #[serde(default, rename = "serial")]
    pub serial_batch: Option<serde_json::Value>,
    #[serde(default)]
    pub max_fail_percentage: Option<u32>,
    #[serde(default)]
    pub environment: Option<HashMap<String, String>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnsibleTask {
    pub name: Option<String>,
    #[serde(default)]
    pub shell: Option<serde_json::Value>,
    #[serde(default)]
    pub command: Option<serde_json::Value>,
    #[serde(default)]
    pub script: Option<String>,
    #[serde(default)]
    pub copy: Option<serde_json::Value>,
    #[serde(default)]
    pub template: Option<serde_json::Value>,
    #[serde(default)]
    pub file: Option<serde_json::Value>,
    #[serde(default)]
    pub apt: Option<serde_json::Value>,
    #[serde(default)]
    pub yum: Option<serde_json::Value>,
    #[serde(default)]
    pub dnf: Option<serde_json::Value>,
    #[serde(default)]
    pub pip: Option<serde_json::Value>,
    #[serde(default)]
    pub systemd: Option<serde_json::Value>,
    #[serde(default)]
    pub service: Option<serde_json::Value>,
    #[serde(default)]
    pub docker_container: Option<serde_json::Value>,
    #[serde(default)]
    pub kubernetes: Option<serde_json::Value>,
    #[serde(default)]
    pub get_url: Option<serde_json::Value>,
    #[serde(default)]
    pub unarchive: Option<serde_json::Value>,
    #[serde(default)]
    pub git: Option<serde_json::Value>,
    #[serde(default)]
    pub cron: Option<serde_json::Value>,
    #[serde(default)]
    pub user: Option<serde_json::Value>,
    #[serde(default)]
    pub group: Option<serde_json::Value>,
    #[serde(default)]
    pub lineinfile: Option<serde_json::Value>,
    #[serde(default)]
    pub replace: Option<serde_json::Value>,
    #[serde(default)]
    pub stat: Option<serde_json::Value>,
    #[serde(default)]
    pub debug: Option<serde_json::Value>,
    #[serde(default)]
    pub assert: Option<serde_json::Value>,
    #[serde(default)]
    pub wait_for: Option<serde_json::Value>,
    #[serde(default)]
    pub pause: Option<serde_json::Value>,
    #[serde(default)]
    pub set_fact: Option<serde_json::Value>,
    #[serde(default)]
    pub register: Option<String>,
    #[serde(default)]
    pub when: Option<serde_json::Value>,
    #[serde(default, rename = "loop")]
    pub loop_items: Option<serde_json::Value>,
    #[serde(default, rename = "with_items")]
    pub with_items: Option<serde_json::Value>,
    #[serde(default, rename = "with_dict")]
    pub with_dict: Option<serde_json::Value>,
    #[serde(default, rename = "with_fileglob")]
    pub with_fileglob: Option<String>,
    #[serde(default)]
    pub until: Option<serde_json::Value>,
    #[serde(default)]
    pub retries: Option<u32>,
    #[serde(default)]
    pub delay: Option<u32>,
    #[serde(default)]
    pub changed_when: Option<serde_json::Value>,
    #[serde(default)]
    pub failed_when: Option<serde_json::Value>,
    #[serde(default)]
    pub ignore_errors: Option<bool>,
    #[serde(default)]
    pub notify: Option<serde_json::Value>,
    #[serde(default)]
    pub tags: Option<serde_json::Value>,
    #[serde(default, rename = "become")]
    pub task_become: Option<bool>,
    #[serde(default, rename = "become_user")]
    pub task_become_user: Option<String>,
    #[serde(default, rename = "delegate_to")]
    pub delegate_to: Option<String>,
    #[serde(default, rename = "local_action")]
    pub local_action: Option<serde_json::Value>,
    // Catch-all for other modules
    #[serde(flatten)]
    pub other: HashMap<String, serde_json::Value>,
}

impl AnsiblePlay {
    pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
        serde_json::from_value(data.clone())
            .map_err(|e| format!("Failed to parse Ansible play: {e}"))
    }

    /// Check if a JSON value looks like an Ansible playbook (top-level array of plays)
    pub fn looks_like_playbook(data: &serde_json::Value) -> bool {
        let arr = match data.as_array() {
            Some(a) => a,
            None => return false,
        };

        // At least one item should have "hosts" (required for a play)
        arr.iter().any(|item| item.get("hosts").is_some())
    }
}

impl ConfigValidator for AnsiblePlaybook {
    fn yaml_type(&self) -> YamlType {
        YamlType::Ansible
    }

    fn validate_structure(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();

        if self.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Error,
                message: "Playbook is empty — no plays defined".into(),
                path: None,
            });
        }

        for (i, play) in self.iter().enumerate() {
            if play.hosts.is_empty() {
                diags.push(Diagnostic {
                    severity: Severity::Error,
                    message: format!("Play[{}]: 'hosts' is required but empty", i),
                    path: Some(format!("[{}] > hosts", i)),
                });
            }
        }

        diags
    }

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();

        for (play_idx, play) in self.iter().enumerate() {
            let play_prefix = format!("[{}]", play_idx);

            // become without become_user
            if play.become_enabled == Some(true) && play.become_user.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: format!("Play[{}]: become=true without become_user — defaults to root", play_idx),
                    path: Some(format!("{} > become_user", play_prefix)),
                });
            }

            // No tasks and no roles
            if play.tasks.is_empty() && play.roles.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Warning,
                    message: format!("Play[{}]: no tasks or roles defined", play_idx),
                    path: Some(format!("{} > tasks", play_prefix)),
                });
            }

            // Check tasks
            for (task_idx, task) in play.tasks.iter().enumerate() {
                let task_path = format!("{} > tasks > {}", play_prefix, task_idx);
                validate_task(task, &task_path, &mut diags);
            }

            // Check pre_tasks
            if let Some(pre_tasks) = &play.pre_tasks {
                for (task_idx, task) in pre_tasks.iter().enumerate() {
                    let task_path = format!("{} > pre_tasks > {}", play_prefix, task_idx);
                    validate_task(task, &task_path, &mut diags);
                }
            }

            // Check post_tasks
            if let Some(post_tasks) = &play.post_tasks {
                for (task_idx, task) in post_tasks.iter().enumerate() {
                    let task_path = format!("{} > post_tasks > {}", play_prefix, task_idx);
                    validate_task(task, &task_path, &mut diags);
                }
            }

            // Check handlers
            if let Some(handlers) = &play.handlers {
                for (task_idx, task) in handlers.iter().enumerate() {
                    let task_path = format!("{} > handlers > {}", play_prefix, task_idx);
                    validate_task(task, &task_path, &mut diags);
                }
            }

            // Play name missing
            if play.name.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: format!("Play[{}]: no 'name' — add for better logging", play_idx),
                    path: Some(play_prefix),
                });
            }
        }

        diags
    }
}

fn validate_task(task: &AnsibleTask, path: &str, diags: &mut Vec<Diagnostic>) {
    // No name
    if task.name.is_none() {
        diags.push(Diagnostic {
            severity: Severity::Info,
            message: format!("Task at '{}' has no 'name' — add for clarity", path),
            path: Some(path.into()),
        });
    }

    // shell vs command
    if task.shell.is_some() {
        diags.push(Diagnostic {
            severity: Severity::Info,
            message: format!("Task at '{}' uses 'shell' module — prefer 'command' when shell features aren't needed", path),
            path: Some(format!("{} > shell", path)),
        });
    }

    // Check for potentially dangerous patterns in shell commands
    if let Some(shell_val) = &task.shell {
        let shell_str = match shell_val {
            serde_json::Value::String(s) => s.clone(),
            serde_json::Value::Object(obj) => {
                obj.get("cmd").and_then(|v| v.as_str()).unwrap_or("").to_string()
            }
            _ => String::new(),
        };

        let dangerous = ["rm -rf", "dd if=", "> /dev/sd", "chmod 777", "curl | bash", "wget | bash"];
        for pattern in dangerous {
            if shell_str.contains(pattern) {
                diags.push(Diagnostic {
                    severity: Severity::Warning,
                    message: format!("Task at '{}' contains potentially dangerous pattern: '{}'", path, pattern),
                    path: Some(format!("{} > shell", path)),
                });
            }
        }
    }

    // apt/yum/dnf without update_cache
    if task.apt.is_some()
        && let Some(obj) = task.apt.as_ref().and_then(|v| v.as_object())
        && !obj.contains_key("update_cache") && obj.contains_key("name")
    {
        diags.push(Diagnostic {
            severity: Severity::Info,
            message: format!("Task at '{}' uses 'apt' without update_cache — may install outdated packages", path),
            path: Some(format!("{} > apt", path)),
        });
    }

    // ignore_errors: true
    if task.ignore_errors == Some(true) {
        diags.push(Diagnostic {
            severity: Severity::Warning,
            message: format!("Task at '{}' has ignore_errors=true — failures will be silently ignored", path),
            path: Some(format!("{} > ignore_errors", path)),
        });
    }

    // No module specified
    let has_module = task.shell.is_some()
        || task.command.is_some()
        || task.script.is_some()
        || task.copy.is_some()
        || task.template.is_some()
        || task.file.is_some()
        || task.apt.is_some()
        || task.yum.is_some()
        || task.dnf.is_some()
        || task.pip.is_some()
        || task.systemd.is_some()
        || task.service.is_some()
        || task.docker_container.is_some()
        || task.kubernetes.is_some()
        || task.get_url.is_some()
        || task.unarchive.is_some()
        || task.git.is_some()
        || task.cron.is_some()
        || task.user.is_some()
        || task.group.is_some()
        || task.lineinfile.is_some()
        || task.replace.is_some()
        || task.stat.is_some()
        || task.debug.is_some()
        || task.assert.is_some()
        || task.wait_for.is_some()
        || task.pause.is_some()
        || task.set_fact.is_some()
        || !task.other.is_empty();

    if !has_module {
        diags.push(Diagnostic {
            severity: Severity::Warning,
            message: format!("Task at '{}' has no recognized module", path),
            path: Some(path.into()),
        });
    }
}