Skip to main content

devops_models/models/
ansible.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5/// Ansible playbook - a list of plays
6pub type AnsiblePlaybook = Vec<AnsiblePlay>;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AnsiblePlay {
10    pub name: Option<String>,
11    pub hosts: String,
12    #[serde(default, rename = "become")]
13    pub become_enabled: Option<bool>,
14    #[serde(default, rename = "become_user")]
15    pub become_user: Option<String>,
16    #[serde(default, rename = "become_method")]
17    pub become_method: Option<String>,
18    #[serde(default)]
19    pub gather_facts: Option<bool>,
20    #[serde(default)]
21    pub vars: Option<HashMap<String, serde_json::Value>>,
22    #[serde(default, rename = "vars_files")]
23    pub vars_files: Option<Vec<String>>,
24    #[serde(default)]
25    pub pre_tasks: Option<Vec<AnsibleTask>>,
26    #[serde(default)]
27    pub tasks: Vec<AnsibleTask>,
28    #[serde(default)]
29    pub post_tasks: Option<Vec<AnsibleTask>>,
30    #[serde(default)]
31    pub handlers: Option<Vec<AnsibleTask>>,
32    #[serde(default)]
33    pub roles: Option<Vec<serde_json::Value>>,
34    #[serde(default)]
35    pub tags: Option<Vec<String>>,
36    #[serde(default, rename = "serial")]
37    pub serial_batch: Option<serde_json::Value>,
38    #[serde(default)]
39    pub max_fail_percentage: Option<u32>,
40    #[serde(default)]
41    pub environment: Option<HashMap<String, String>>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct AnsibleTask {
46    pub name: Option<String>,
47    #[serde(default)]
48    pub shell: Option<serde_json::Value>,
49    #[serde(default)]
50    pub command: Option<serde_json::Value>,
51    #[serde(default)]
52    pub script: Option<String>,
53    #[serde(default)]
54    pub copy: Option<serde_json::Value>,
55    #[serde(default)]
56    pub template: Option<serde_json::Value>,
57    #[serde(default)]
58    pub file: Option<serde_json::Value>,
59    #[serde(default)]
60    pub apt: Option<serde_json::Value>,
61    #[serde(default)]
62    pub yum: Option<serde_json::Value>,
63    #[serde(default)]
64    pub dnf: Option<serde_json::Value>,
65    #[serde(default)]
66    pub pip: Option<serde_json::Value>,
67    #[serde(default)]
68    pub systemd: Option<serde_json::Value>,
69    #[serde(default)]
70    pub service: Option<serde_json::Value>,
71    #[serde(default)]
72    pub docker_container: Option<serde_json::Value>,
73    #[serde(default)]
74    pub kubernetes: Option<serde_json::Value>,
75    #[serde(default)]
76    pub get_url: Option<serde_json::Value>,
77    #[serde(default)]
78    pub unarchive: Option<serde_json::Value>,
79    #[serde(default)]
80    pub git: Option<serde_json::Value>,
81    #[serde(default)]
82    pub cron: Option<serde_json::Value>,
83    #[serde(default)]
84    pub user: Option<serde_json::Value>,
85    #[serde(default)]
86    pub group: Option<serde_json::Value>,
87    #[serde(default)]
88    pub lineinfile: Option<serde_json::Value>,
89    #[serde(default)]
90    pub replace: Option<serde_json::Value>,
91    #[serde(default)]
92    pub stat: Option<serde_json::Value>,
93    #[serde(default)]
94    pub debug: Option<serde_json::Value>,
95    #[serde(default)]
96    pub assert: Option<serde_json::Value>,
97    #[serde(default)]
98    pub wait_for: Option<serde_json::Value>,
99    #[serde(default)]
100    pub pause: Option<serde_json::Value>,
101    #[serde(default)]
102    pub set_fact: Option<serde_json::Value>,
103    #[serde(default)]
104    pub register: Option<String>,
105    #[serde(default)]
106    pub when: Option<serde_json::Value>,
107    #[serde(default, rename = "loop")]
108    pub loop_items: Option<serde_json::Value>,
109    #[serde(default, rename = "with_items")]
110    pub with_items: Option<serde_json::Value>,
111    #[serde(default, rename = "with_dict")]
112    pub with_dict: Option<serde_json::Value>,
113    #[serde(default, rename = "with_fileglob")]
114    pub with_fileglob: Option<String>,
115    #[serde(default)]
116    pub until: Option<serde_json::Value>,
117    #[serde(default)]
118    pub retries: Option<u32>,
119    #[serde(default)]
120    pub delay: Option<u32>,
121    #[serde(default)]
122    pub changed_when: Option<serde_json::Value>,
123    #[serde(default)]
124    pub failed_when: Option<serde_json::Value>,
125    #[serde(default)]
126    pub ignore_errors: Option<bool>,
127    #[serde(default)]
128    pub notify: Option<serde_json::Value>,
129    #[serde(default)]
130    pub tags: Option<serde_json::Value>,
131    #[serde(default, rename = "become")]
132    pub task_become: Option<bool>,
133    #[serde(default, rename = "become_user")]
134    pub task_become_user: Option<String>,
135    #[serde(default, rename = "delegate_to")]
136    pub delegate_to: Option<String>,
137    #[serde(default, rename = "local_action")]
138    pub local_action: Option<serde_json::Value>,
139    // Catch-all for other modules
140    #[serde(flatten)]
141    pub other: HashMap<String, serde_json::Value>,
142}
143
144impl AnsiblePlay {
145    pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
146        serde_json::from_value(data.clone())
147            .map_err(|e| format!("Failed to parse Ansible play: {e}"))
148    }
149
150    /// Check if a JSON value looks like an Ansible playbook (top-level array of plays)
151    pub fn looks_like_playbook(data: &serde_json::Value) -> bool {
152        let arr = match data.as_array() {
153            Some(a) => a,
154            None => return false,
155        };
156
157        // At least one item should have "hosts" (required for a play)
158        arr.iter().any(|item| item.get("hosts").is_some())
159    }
160}
161
162impl ConfigValidator for AnsiblePlaybook {
163    fn yaml_type(&self) -> YamlType {
164        YamlType::Ansible
165    }
166
167    fn validate_structure(&self) -> Vec<Diagnostic> {
168        let mut diags = Vec::new();
169
170        if self.is_empty() {
171            diags.push(Diagnostic {
172                severity: Severity::Error,
173                message: "Playbook is empty — no plays defined".into(),
174                path: None,
175            });
176        }
177
178        for (i, play) in self.iter().enumerate() {
179            if play.hosts.is_empty() {
180                diags.push(Diagnostic {
181                    severity: Severity::Error,
182                    message: format!("Play[{}]: 'hosts' is required but empty", i),
183                    path: Some(format!("[{}] > hosts", i)),
184                });
185            }
186        }
187
188        diags
189    }
190
191    fn validate_semantics(&self) -> Vec<Diagnostic> {
192        let mut diags = Vec::new();
193
194        for (play_idx, play) in self.iter().enumerate() {
195            let play_prefix = format!("[{}]", play_idx);
196
197            // become without become_user
198            if play.become_enabled == Some(true) && play.become_user.is_none() {
199                diags.push(Diagnostic {
200                    severity: Severity::Info,
201                    message: format!("Play[{}]: become=true without become_user — defaults to root", play_idx),
202                    path: Some(format!("{} > become_user", play_prefix)),
203                });
204            }
205
206            // No tasks and no roles
207            if play.tasks.is_empty() && play.roles.is_none() {
208                diags.push(Diagnostic {
209                    severity: Severity::Warning,
210                    message: format!("Play[{}]: no tasks or roles defined", play_idx),
211                    path: Some(format!("{} > tasks", play_prefix)),
212                });
213            }
214
215            // Check tasks
216            for (task_idx, task) in play.tasks.iter().enumerate() {
217                let task_path = format!("{} > tasks > {}", play_prefix, task_idx);
218                validate_task(task, &task_path, &mut diags);
219            }
220
221            // Check pre_tasks
222            if let Some(pre_tasks) = &play.pre_tasks {
223                for (task_idx, task) in pre_tasks.iter().enumerate() {
224                    let task_path = format!("{} > pre_tasks > {}", play_prefix, task_idx);
225                    validate_task(task, &task_path, &mut diags);
226                }
227            }
228
229            // Check post_tasks
230            if let Some(post_tasks) = &play.post_tasks {
231                for (task_idx, task) in post_tasks.iter().enumerate() {
232                    let task_path = format!("{} > post_tasks > {}", play_prefix, task_idx);
233                    validate_task(task, &task_path, &mut diags);
234                }
235            }
236
237            // Check handlers
238            if let Some(handlers) = &play.handlers {
239                for (task_idx, task) in handlers.iter().enumerate() {
240                    let task_path = format!("{} > handlers > {}", play_prefix, task_idx);
241                    validate_task(task, &task_path, &mut diags);
242                }
243            }
244
245            // Play name missing
246            if play.name.is_none() {
247                diags.push(Diagnostic {
248                    severity: Severity::Info,
249                    message: format!("Play[{}]: no 'name' — add for better logging", play_idx),
250                    path: Some(play_prefix),
251                });
252            }
253        }
254
255        diags
256    }
257}
258
259fn validate_task(task: &AnsibleTask, path: &str, diags: &mut Vec<Diagnostic>) {
260    // No name
261    if task.name.is_none() {
262        diags.push(Diagnostic {
263            severity: Severity::Info,
264            message: format!("Task at '{}' has no 'name' — add for clarity", path),
265            path: Some(path.into()),
266        });
267    }
268
269    // shell vs command
270    if task.shell.is_some() {
271        diags.push(Diagnostic {
272            severity: Severity::Info,
273            message: format!("Task at '{}' uses 'shell' module — prefer 'command' when shell features aren't needed", path),
274            path: Some(format!("{} > shell", path)),
275        });
276    }
277
278    // Check for potentially dangerous patterns in shell commands
279    if let Some(shell_val) = &task.shell {
280        let shell_str = match shell_val {
281            serde_json::Value::String(s) => s.clone(),
282            serde_json::Value::Object(obj) => {
283                obj.get("cmd").and_then(|v| v.as_str()).unwrap_or("").to_string()
284            }
285            _ => String::new(),
286        };
287
288        let dangerous = ["rm -rf", "dd if=", "> /dev/sd", "chmod 777", "curl | bash", "wget | bash"];
289        for pattern in dangerous {
290            if shell_str.contains(pattern) {
291                diags.push(Diagnostic {
292                    severity: Severity::Warning,
293                    message: format!("Task at '{}' contains potentially dangerous pattern: '{}'", path, pattern),
294                    path: Some(format!("{} > shell", path)),
295                });
296            }
297        }
298    }
299
300    // apt/yum/dnf without update_cache
301    if task.apt.is_some()
302        && let Some(obj) = task.apt.as_ref().and_then(|v| v.as_object())
303        && !obj.contains_key("update_cache") && obj.contains_key("name")
304    {
305        diags.push(Diagnostic {
306            severity: Severity::Info,
307            message: format!("Task at '{}' uses 'apt' without update_cache — may install outdated packages", path),
308            path: Some(format!("{} > apt", path)),
309        });
310    }
311
312    // ignore_errors: true
313    if task.ignore_errors == Some(true) {
314        diags.push(Diagnostic {
315            severity: Severity::Warning,
316            message: format!("Task at '{}' has ignore_errors=true — failures will be silently ignored", path),
317            path: Some(format!("{} > ignore_errors", path)),
318        });
319    }
320
321    // No module specified
322    let has_module = task.shell.is_some()
323        || task.command.is_some()
324        || task.script.is_some()
325        || task.copy.is_some()
326        || task.template.is_some()
327        || task.file.is_some()
328        || task.apt.is_some()
329        || task.yum.is_some()
330        || task.dnf.is_some()
331        || task.pip.is_some()
332        || task.systemd.is_some()
333        || task.service.is_some()
334        || task.docker_container.is_some()
335        || task.kubernetes.is_some()
336        || task.get_url.is_some()
337        || task.unarchive.is_some()
338        || task.git.is_some()
339        || task.cron.is_some()
340        || task.user.is_some()
341        || task.group.is_some()
342        || task.lineinfile.is_some()
343        || task.replace.is_some()
344        || task.stat.is_some()
345        || task.debug.is_some()
346        || task.assert.is_some()
347        || task.wait_for.is_some()
348        || task.pause.is_some()
349        || task.set_fact.is_some()
350        || !task.other.is_empty();
351
352    if !has_module {
353        diags.push(Diagnostic {
354            severity: Severity::Warning,
355            message: format!("Task at '{}' has no recognized module", path),
356            path: Some(path.into()),
357        });
358    }
359}