ansible/
playbook.rs

1//! Ansible playbook execution and management.
2//!
3//! This module provides the [`Playbook`] struct for executing Ansible playbooks
4//! and the [`Play`] enum for representing different types of playbook content.
5
6use crate::command_config::CommandConfig;
7use crate::errors::{AnsibleError, Result};
8use std::ffi::OsStr;
9use std::fmt::{Display, Formatter};
10use std::io::Write;
11use std::process;
12
13/// Ansible playbook executor with comprehensive configuration options.
14///
15/// The `Playbook` struct provides a fluent interface for configuring and executing
16/// Ansible playbooks. It supports all major ansible-playbook command-line options
17/// and provides type-safe configuration.
18///
19/// # Examples
20///
21/// ## Basic Playbook Execution
22///
23/// ```rust,no_run
24/// use ansible::{Playbook, Play};
25///
26/// let mut playbook = Playbook::default();
27/// playbook.set_inventory("hosts.yml");
28///
29/// // Run from file
30/// let result = playbook.run(Play::from_file("site.yml"))?;
31/// println!("Playbook result: {}", result);
32/// # Ok::<(), ansible::AnsibleError>(())
33/// ```
34///
35/// ## Advanced Configuration
36///
37/// ```rust,no_run
38/// use ansible::{Playbook, Play};
39///
40/// let mut playbook = Playbook::default();
41/// playbook
42///     .set_inventory("production")
43///     .set_verbosity(2)
44///     .add_extra_var("env", "production")
45///     .add_extra_var("version", "1.2.3")
46///     .add_tag("deploy")
47///     .add_tag("config")
48///     .set_check_mode(true);
49///
50/// let result = playbook.run(Play::from_file("deploy.yml"))?;
51/// # Ok::<(), ansible::AnsibleError>(())
52/// ```
53///
54/// ## Environment Configuration
55///
56/// ```rust,no_run
57/// use ansible::Playbook;
58///
59/// let mut playbook = Playbook::default();
60/// playbook
61///     .set_system_envs()
62///     .filter_envs(["HOME", "PATH", "USER"])
63///     .add_env("ANSIBLE_HOST_KEY_CHECKING", "False")
64///     .add_env("ANSIBLE_STDOUT_CALLBACK", "json");
65/// # Ok::<(), ansible::AnsibleError>(())
66/// ```
67#[derive(Debug, Clone)]
68pub struct Playbook {
69    pub(crate) command: String,
70    pub(crate) cfg: CommandConfig,
71    pub(crate) inventory: Option<String>,
72}
73
74impl Default for Playbook {
75    fn default() -> Self {
76        Self {
77            command: "ansible-playbook".into(),
78            cfg: CommandConfig::default(),
79            inventory: None,
80        }
81    }
82}
83
84impl Display for Playbook {
85    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
86        write!(f, "{}", self.command)?;
87
88        if let Some(ref inventory) = self.inventory {
89            if !inventory.is_empty() {
90                write!(f, " -i {}", inventory)?;
91            }
92        }
93
94        if !self.cfg.args.is_empty() {
95            write!(f, " {}", self.cfg.args.join(" "))?;
96        }
97
98        Ok(())
99    }
100}
101
102impl Playbook {
103    /// Set environment variables from the current system environment.
104    ///
105    /// This method copies all environment variables from the current process
106    /// to be passed to the ansible-playbook command.
107    ///
108    /// # Examples
109    ///
110    /// ```rust
111    /// use ansible::Playbook;
112    ///
113    /// let mut playbook = Playbook::default();
114    /// playbook.set_system_envs();
115    /// ```
116    pub fn set_system_envs(&mut self) -> &mut Self {
117        self.cfg.set_system_envs();
118        self
119    }
120
121    /// Filter environment variables to only include specified keys.
122    ///
123    /// This method is useful for limiting which environment variables
124    /// are passed to ansible-playbook commands.
125    ///
126    /// # Examples
127    ///
128    /// ```rust
129    /// use ansible::Playbook;
130    ///
131    /// let mut playbook = Playbook::default();
132    /// playbook
133    ///     .set_system_envs()
134    ///     .filter_envs(["HOME", "PATH", "USER"]);
135    /// ```
136    pub fn filter_envs<T, S>(&mut self, iter: T) -> &mut Self
137    where
138        T: IntoIterator<Item = S>,
139        S: AsRef<OsStr> + Display,
140    {
141        self.cfg.filter_envs(iter);
142        self
143    }
144
145    /// Add a single environment variable.
146    ///
147    /// This method adds or overwrites an environment variable that will
148    /// be passed to the ansible-playbook command.
149    ///
150    /// # Examples
151    ///
152    /// ```rust
153    /// use ansible::Playbook;
154    ///
155    /// let mut playbook = Playbook::default();
156    /// playbook
157    ///     .add_env("ANSIBLE_HOST_KEY_CHECKING", "False")
158    ///     .add_env("ANSIBLE_STDOUT_CALLBACK", "json");
159    /// ```
160    pub fn add_env(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
161        self.cfg.add_env(key, value);
162        self
163    }
164
165    /// Add a single command-line argument.
166    ///
167    /// This method adds a raw command-line argument to the ansible-playbook command.
168    ///
169    /// # Examples
170    ///
171    /// ```rust
172    /// use ansible::Playbook;
173    ///
174    /// let mut playbook = Playbook::default();
175    /// playbook
176    ///     .arg("--verbose")
177    ///     .arg("--check");
178    /// ```
179    pub fn arg<S: AsRef<OsStr> + Display>(&mut self, arg: S) -> &mut Self {
180        self.cfg.args.push(arg.to_string());
181        self
182    }
183
184    /// Add multiple command-line arguments.
185    ///
186    /// This method adds multiple raw command-line arguments to the ansible-playbook command.
187    ///
188    /// # Examples
189    ///
190    /// ```rust
191    /// use ansible::Playbook;
192    ///
193    /// let mut playbook = Playbook::default();
194    /// playbook.args(["--verbose", "--check", "--diff"]);
195    /// ```
196    pub fn args<T, S>(&mut self, args: T) -> &mut Self
197    where
198        T: IntoIterator<Item = S>,
199        S: AsRef<OsStr> + Display,
200    {
201        for arg in args {
202            self.arg(arg);
203        }
204        self
205    }
206
207    /// Set the inventory file or directory.
208    ///
209    /// This method specifies the inventory file or directory to use
210    /// for host and group information.
211    ///
212    /// # Examples
213    ///
214    /// ```rust
215    /// use ansible::Playbook;
216    ///
217    /// let mut playbook = Playbook::default();
218    /// playbook.set_inventory("hosts.yml");
219    /// playbook.set_inventory("/etc/ansible/hosts");
220    /// playbook.set_inventory("production");
221    /// ```
222    pub fn set_inventory(&mut self, s: &str) -> &mut Self {
223        self.inventory = Some(s.to_string());
224        self
225    }
226
227    /// Configure output to use JSON format.
228    ///
229    /// This method sets environment variables to configure ansible-playbook
230    /// to output results in JSON format.
231    ///
232    /// # Examples
233    ///
234    /// ```rust
235    /// use ansible::Playbook;
236    ///
237    /// let mut playbook = Playbook::default();
238    /// playbook.set_output_json();
239    /// ```
240    pub fn set_output_json(&mut self) -> &mut Self {
241        self.cfg
242            .add_env("ANSIBLE_STDOUT_CALLBACK", "json")
243            .add_env("ANSIBLE_LOAD_CALLBACK_PLUGINS", "True");
244        self
245    }
246
247    /// Set the verbosity level for playbook execution.
248    ///
249    /// This method sets the verbosity level for ansible-playbook output.
250    ///
251    /// # Examples
252    ///
253    /// ```rust
254    /// use ansible::Playbook;
255    ///
256    /// let mut playbook = Playbook::default();
257    /// playbook.set_verbosity(2); // -vv
258    /// ```
259    pub fn set_verbosity(&mut self, level: u8) -> &mut Self {
260        let verbose_arg = format!("-{}", "v".repeat(level as usize));
261        self.arg(verbose_arg);
262        self
263    }
264
265    /// Add an extra variable for playbook execution.
266    ///
267    /// This method adds extra variables that will be passed to the playbook.
268    ///
269    /// # Examples
270    ///
271    /// ```rust
272    /// use ansible::Playbook;
273    ///
274    /// let mut playbook = Playbook::default();
275    /// playbook
276    ///     .add_extra_var("env", "production")
277    ///     .add_extra_var("version", "1.2.3");
278    /// ```
279    pub fn add_extra_var(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
280        let var_string = format!("{}={}", key.into(), value.into());
281        self.arg("--extra-vars").arg(var_string);
282        self
283    }
284
285    /// Add a tag to limit playbook execution.
286    ///
287    /// This method adds tags to limit which tasks are executed.
288    ///
289    /// # Examples
290    ///
291    /// ```rust
292    /// use ansible::Playbook;
293    ///
294    /// let mut playbook = Playbook::default();
295    /// playbook
296    ///     .add_tag("deploy")
297    ///     .add_tag("config");
298    /// ```
299    pub fn add_tag(&mut self, tag: impl Into<String>) -> &mut Self {
300        self.arg("--tags").arg(tag.into());
301        self
302    }
303
304    /// Enable check mode (dry run).
305    ///
306    /// This method enables check mode for the playbook execution.
307    ///
308    /// # Examples
309    ///
310    /// ```rust
311    /// use ansible::Playbook;
312    ///
313    /// let mut playbook = Playbook::default();
314    /// playbook.set_check_mode(true);
315    /// ```
316    pub fn set_check_mode(&mut self, enabled: bool) -> &mut Self {
317        if enabled {
318            self.arg("--check");
319        }
320        self
321    }
322
323    /// Execute an Ansible playbook.
324    ///
325    /// This method executes an Ansible playbook from either a file or string content.
326    /// It handles temporary file creation for string content and cleanup afterwards.
327    ///
328    /// # Arguments
329    ///
330    /// * `play` - The playbook source (file path or content)
331    ///
332    /// # Returns
333    ///
334    /// Returns the combined stdout and stderr output from the ansible-playbook command.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if:
339    /// - The playbook file cannot be read or created
340    /// - The ansible-playbook command fails to execute
341    /// - The command returns a non-zero exit code
342    ///
343    /// # Examples
344    ///
345    /// ## Running from File
346    ///
347    /// ```rust,no_run
348    /// use ansible::{Playbook, Play};
349    ///
350    /// let mut playbook = Playbook::default();
351    /// playbook.set_inventory("hosts.yml");
352    ///
353    /// let result = playbook.run(Play::from_file("site.yml"))?;
354    /// println!("Playbook result: {}", result);
355    /// # Ok::<(), ansible::AnsibleError>(())
356    /// ```
357    ///
358    /// ## Running from Content
359    ///
360    /// ```rust,no_run
361    /// use ansible::{Playbook, Play};
362    ///
363    /// let yaml_content = r#"
364    /// - hosts: all
365    ///   tasks:
366    ///     - name: Ensure nginx is installed
367    ///       package:
368    ///         name: nginx
369    ///         state: present
370    /// "#;
371    ///
372    /// let mut playbook = Playbook::default();
373    /// let result = playbook.run(Play::from_content(yaml_content))?;
374    /// # Ok::<(), ansible::AnsibleError>(())
375    /// ```
376    pub fn run(&self, play: Play) -> Result<String> {
377        let (playbook_path, is_temp) = match play {
378            Play::File(path) => (path, false),
379            Play::Content(content) => {
380                let temp_dir = std::env::temp_dir();
381                let temp_file = temp_dir.join("ansible_playbook.yaml");
382                let mut f = std::fs::File::create(&temp_file)?;
383                write!(f, "{}", content)?;
384                (temp_file.to_string_lossy().to_string(), true)
385            }
386        };
387        let full_cmd = self.to_string();
388        let cmd_vec: Vec<&str> = full_cmd.split_whitespace().collect();
389        let mut cmd = process::Command::new(&self.command);
390        cmd.envs(&self.cfg.envs);
391        cmd.args(&cmd_vec.as_slice()[1..]);
392        cmd.args(&self.cfg.args);
393        cmd.arg(&playbook_path);
394        let output = cmd.output()?;
395
396        if !output.status.success() {
397            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
398            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
399            return Err(AnsibleError::command_failed(
400                "Ansible playbook execution failed",
401                output.status.code(),
402                Some(stdout),
403                Some(stderr),
404            ));
405        }
406
407        let result = [output.stdout, "\n".as_bytes().to_vec(), output.stderr].concat();
408        let output_str = String::from_utf8_lossy(&result).to_string();
409
410        // Clean up temporary file if it was created
411        if is_temp {
412            std::fs::remove_file(&playbook_path)?;
413        }
414
415        Ok(output_str)
416    }
417}
418
419/// Represents different sources of playbook content.
420///
421/// The `Play` enum allows you to specify playbook content either from
422/// a file on disk or from a string containing YAML content.
423///
424/// # Examples
425///
426/// ## From File
427///
428/// ```rust
429/// use ansible::Play;
430///
431/// let play = Play::from_file("site.yml");
432/// let play = Play::from_file("/path/to/playbook.yml");
433/// ```
434///
435/// ## From Content
436///
437/// ```rust
438/// use ansible::Play;
439///
440/// let yaml_content = r#"
441/// - hosts: all
442///   tasks:
443///     - name: Ensure nginx is installed
444///       package:
445///         name: nginx
446///         state: present
447/// "#;
448///
449/// let play = Play::from_content(yaml_content);
450/// ```
451#[derive(Debug, Clone)]
452pub enum Play {
453    /// Playbook content loaded from a file path
454    ///
455    /// The file should contain valid YAML playbook content.
456    File(String),
457
458    /// Playbook content provided as a string
459    ///
460    /// The string should contain valid YAML playbook content.
461    Content(String),
462}
463
464impl Play {
465    /// Create a Play from a file path.
466    ///
467    /// This method creates a Play that will read playbook content from
468    /// the specified file when executed.
469    ///
470    /// # Arguments
471    ///
472    /// * `path` - Path to the playbook file (relative or absolute)
473    ///
474    /// # Examples
475    ///
476    /// ```rust
477    /// use ansible::Play;
478    ///
479    /// let play = Play::from_file("site.yml");
480    /// let play = Play::from_file("/path/to/playbook.yml");
481    /// let play = Play::from_file("playbooks/deploy.yml");
482    /// ```
483    pub fn from_file(path: impl Into<String>) -> Self {
484        Play::File(path.into())
485    }
486
487    /// Create a Play from string content.
488    ///
489    /// This method creates a Play from YAML content provided as a string.
490    /// The content will be written to a temporary file when executed.
491    ///
492    /// # Arguments
493    ///
494    /// * `content` - YAML playbook content as a string
495    ///
496    /// # Examples
497    ///
498    /// ```rust
499    /// use ansible::Play;
500    ///
501    /// let yaml_content = r#"
502    /// - hosts: all
503    ///   become: yes
504    ///   tasks:
505    ///     - name: Update package cache
506    ///       apt:
507    ///         update_cache: yes
508    ///       when: ansible_os_family == "Debian"
509    ///
510    ///     - name: Install essential packages
511    ///       package:
512    ///         name:
513    ///           - curl
514    ///           - wget
515    ///           - git
516    ///         state: present
517    /// "#;
518    ///
519    /// let play = Play::from_content(yaml_content);
520    /// ```
521    pub fn from_content(content: impl Into<String>) -> Self {
522        Play::Content(content.into())
523    }
524}