1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5pub 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}