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};

// ═══════════════════════════════════════════════════════════════════════════
// GitHub Actions Workflow
// ═══════════════════════════════════════════════════════════════════════════

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GHStep {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub uses: Option<String>,
    #[serde(default)]
    pub run: Option<String>,
    #[serde(default, rename = "with")]
    pub with_inputs: Option<serde_json::Value>,
    #[serde(default)]
    pub env: Option<serde_json::Value>,
    #[serde(default, rename = "if")]
    pub condition: Option<String>,
    #[serde(default)]
    pub id: Option<String>,
    #[serde(default, rename = "working-directory")]
    pub working_directory: Option<String>,
    #[serde(default)]
    pub shell: Option<String>,
    #[serde(default, rename = "continue-on-error")]
    pub continue_on_error: Option<serde_json::Value>,
    #[serde(default, rename = "timeout-minutes")]
    pub timeout_minutes: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GHContainer {
    pub image: String,
    #[serde(default)]
    pub credentials: Option<serde_json::Value>,
    #[serde(default)]
    pub env: Option<serde_json::Value>,
    #[serde(default)]
    pub ports: Vec<serde_json::Value>,
    #[serde(default)]
    pub volumes: Vec<String>,
    #[serde(default)]
    pub options: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GHStrategy {
    #[serde(default)]
    pub matrix: Option<serde_json::Value>,
    #[serde(default, rename = "fail-fast")]
    pub fail_fast: Option<bool>,
    #[serde(default, rename = "max-parallel")]
    pub max_parallel: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GHJob {
    #[serde(default, rename = "runs-on")]
    pub runs_on: Option<serde_json::Value>,
    #[serde(default)]
    pub steps: Vec<GHStep>,
    #[serde(default)]
    pub needs: Option<serde_json::Value>,
    #[serde(default)]
    pub strategy: Option<GHStrategy>,
    #[serde(default)]
    pub env: Option<serde_json::Value>,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default, rename = "if")]
    pub condition: Option<String>,
    #[serde(default, rename = "timeout-minutes")]
    pub timeout_minutes: Option<u32>,
    #[serde(default, rename = "continue-on-error")]
    pub continue_on_error: Option<serde_json::Value>,
    #[serde(default)]
    pub container: Option<serde_json::Value>,
    #[serde(default)]
    pub services: Option<serde_json::Value>,
    #[serde(default)]
    pub permissions: Option<serde_json::Value>,
    #[serde(default)]
    pub outputs: Option<serde_json::Value>,
    #[serde(default)]
    pub concurrency: Option<serde_json::Value>,
    #[serde(default)]
    pub defaults: Option<serde_json::Value>,
    #[serde(default)]
    pub uses: Option<String>,
    #[serde(default, rename = "with")]
    pub with_inputs: Option<serde_json::Value>,
    #[serde(default)]
    pub secrets: Option<serde_json::Value>,
    #[serde(default, rename = "environment")]
    pub environment: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubActions {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub on: Option<serde_json::Value>,
    #[serde(default)]
    pub env: Option<serde_json::Value>,
    #[serde(default)]
    pub permissions: Option<serde_json::Value>,
    #[serde(default)]
    pub concurrency: Option<serde_json::Value>,
    #[serde(default)]
    pub defaults: Option<serde_json::Value>,
    /// Jobs map — the core of the workflow
    #[serde(default)]
    pub jobs: HashMap<String, GHJob>,
}

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

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

    fn validate_structure(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        if self.on.is_none() {
            diags.push(Diagnostic {
                severity: Severity::Error,
                message: "'on' trigger is required".into(),
                path: Some("on".into()),
            });
        }
        if self.jobs.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Error,
                message: "No jobs defined".into(),
                path: Some("jobs".into()),
            });
        }
        for (name, job) in &self.jobs {
            if job.runs_on.is_none() && job.uses.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Error,
                    message: format!("Job '{}': must specify 'runs-on' or 'uses' (reusable workflow)", name),
                    path: Some(format!("jobs > {}", name)),
                });
            }
            if job.steps.is_empty() && job.uses.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Error,
                    message: format!("Job '{}': no steps defined", name),
                    path: Some(format!("jobs > {} > steps", name)),
                });
            }
        }
        diags
    }

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        let job_names: Vec<&String> = self.jobs.keys().collect();

        for (name, job) in &self.jobs {
            // Check needs references
            if let Some(needs) = &job.needs {
                let needed: Vec<String> = match needs {
                    serde_json::Value::String(s) => vec![s.clone()],
                    serde_json::Value::Array(arr) => {
                        arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()
                    }
                    _ => vec![],
                };
                for dep in &needed {
                    if !job_names.contains(&dep) {
                        diags.push(Diagnostic {
                            severity: Severity::Warning,
                            message: format!("Job '{}': needs '{}' which is not defined in this workflow", name, dep),
                            path: Some(format!("jobs > {} > needs", name)),
                        });
                    }
                }
            }

            // No timeout
            if job.timeout_minutes.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: format!("Job '{}': no timeout-minutes — defaults to 360 (6 hours)", name),
                    path: Some(format!("jobs > {} > timeout-minutes", name)),
                });
            }

            // Step analysis
            let has_checkout = job.steps.iter().any(|s| {
                s.uses.as_deref().map(|u| u.starts_with("actions/checkout")).unwrap_or(false)
            });

            let has_run = job.steps.iter().any(|s| s.run.is_some());

            if has_run && !has_checkout && job.uses.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: format!("Job '{}': has 'run' steps but no actions/checkout — repo code won't be available", name),
                    path: Some(format!("jobs > {} > steps", name)),
                });
            }

            for (i, step) in job.steps.iter().enumerate() {
                // Action version pinning
                if let Some(uses) = &step.uses
                    && (uses.contains("@master") || uses.contains("@main")) {
                        diags.push(Diagnostic {
                            severity: Severity::Warning,
                            message: format!("Job '{}' step[{}]: '{}' uses branch ref instead of tag/SHA — may break unexpectedly", name, i, uses),
                            path: Some(format!("jobs > {} > steps > {} > uses", name, i)),
                        });
                    }

                // Step must have uses or run
                if step.uses.is_none() && step.run.is_none() {
                    diags.push(Diagnostic {
                        severity: Severity::Warning,
                        message: format!("Job '{}' step[{}]: has neither 'uses' nor 'run'", name, i),
                        path: Some(format!("jobs > {} > steps > {}", name, i)),
                    });
                }
            }
        }

        if self.name.is_none() {
            diags.push(Diagnostic {
                severity: Severity::Info,
                message: "No workflow 'name' — file name will be used as identifier".into(),
                path: Some("name".into()),
            });
        }

        diags
    }
}