Skip to main content

isoloom_render/
inventory.rs

1//! The one Ansible inventory, rendered from a planned [`Environment`] for every target that runs
2//! playbooks: the controller's `/etc/isoloom/inventory.ini` on Vagrant, Proxmox and the clouds,
3//! and `.isoloom/external/inventory.ini` on the `external` target.
4//!
5//! What is the same everywhere lives here: which machines are hosts ([`hosts`]), the `linux` and
6//! `windows` groups, each host's `ansible_host` and its `host_vars`, `ansible_become` on Linux,
7//! and the inventory groups (`provision[].groups`, then the spec's `groups:`, members limited to
8//! the hosts). What differs per target is how a host is reached (users, keys, passwords, WinRM
9//! or SSH on Windows), which the target says with an [`Access`]. The presets ([`Access::vagrant`],
10//! [`Access::proxmox`], [`Access::cloud`], [`Access::external`]) are the targets' own.
11//!
12//! ```text
13//! [linux]
14//! <host> ansible_host=<address> <Access::host vars> <host_vars>
15//!
16//! [windows]
17//! <host> ansible_host=<address> <Access::host vars> <host_vars>
18//!
19//! [linux:vars]
20//! <Access::linux>
21//! ansible_become=true
22//!
23//! [windows:vars]          when a Windows host exists and the access sets Windows variables
24//! <Access::windows>
25//!
26//! [<group>]               each inventory group: its members that are hosts here
27//! <host>
28//! ```
29//!
30//! A backend calls `inventory::render(env, &Access::vagrant())` and writes the text where its
31//! controller (or the user) runs Ansible; a header, if the file has one, is the caller's.
32
33use std::fmt;
34
35use indexmap::IndexMap;
36
37use isoloom_plan::{Environment, MachinePlan, MachineShape};
38use isoloom_spec::model::{Target, Winrm};
39
40/// Variables, in the order they are written.
41pub type Vars = Vec<(String, String)>;
42
43/// The controller's own private key, which every Linux VM it provisions trusts.
44pub const CONTROLLER_KEY: &str = "/etc/isoloom/id_ed25519";
45
46/// One host of the inventory: a machine Ansible reaches on this target.
47#[derive(Debug, Clone)]
48#[non_exhaustive]
49pub struct Host<'a> {
50    /// The machine's name, the inventory hostname.
51    pub name: &'a str,
52    /// Its `ansible_host`: the existing host's address on `external`, else its first address.
53    pub address: String,
54    /// The machine as planned.
55    pub machine: &'a MachinePlan,
56}
57
58/// How a target reaches its hosts.
59#[non_exhaustive]
60pub struct Access<'a> {
61    /// `[linux:vars]`, before the `ansible_become=true` every Linux host gets.
62    pub linux: Vars,
63    /// `[windows:vars]`, written when a Windows host exists.
64    pub windows: Vars,
65    /// Each host's own connection variables, after its `ansible_host`.
66    pub host: Box<dyn Fn(&Host<'_>) -> Vars + 'a>,
67}
68
69impl fmt::Debug for Access<'_> {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.debug_struct("Access")
72            .field("linux", &self.linux)
73            .field("windows", &self.windows)
74            .finish_non_exhaustive()
75    }
76}
77
78fn vars(pairs: &[(&str, &str)]) -> Vars {
79    pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
80}
81
82/// WinRM's certificate check off and its timeouts longer than Ansible's defaults: a domain
83/// controller's promotion or a reboot keeps a call waiting.
84const WINRM_PATIENCE: [(&str, &str); 3] = [
85    ("ansible_winrm_server_cert_validation", "ignore"),
86    ("ansible_winrm_operation_timeout_sec", "400"),
87    ("ansible_winrm_read_timeout_sec", "500"),
88];
89
90/// The WinRM port, scheme and transport of a Windows machine that answers `winrm` that way.
91#[must_use]
92pub fn winrm_vars(winrm: Winrm) -> Vars {
93    match winrm {
94        Winrm::Ssl => vars(&[("ansible_port", "5986"), ("ansible_winrm_scheme", "https"), ("ansible_winrm_transport", "ntlm")]),
95        Winrm::Plaintext => vars(&[("ansible_port", "5985"), ("ansible_winrm_scheme", "http"), ("ansible_winrm_transport", "basic")]),
96    }
97}
98
99impl Access<'static> {
100    /// Vagrant: the boxes' own `vagrant` account (password auth); WinRM set per box, so a lab
101    /// can mix plain-HTTP and HTTPS Windows boxes, and a box reached over OpenSSH instead
102    /// (`vm.image.winssh`) gets SSH with PowerShell on its line.
103    #[must_use]
104    pub fn vagrant() -> Access<'static> {
105        let account = [("ansible_user", "vagrant"), ("ansible_password", "vagrant")];
106        let mut windows = vars(&account);
107        windows.extend(vars(&[("ansible_connection", "winrm")]));
108        windows.extend(vars(&WINRM_PATIENCE));
109        Access {
110            linux: vars(&account),
111            windows,
112            host: Box::new(|h| {
113                if h.machine.winssh {
114                    vars(&WINDOWS_OVER_SSH)
115                } else {
116                    h.machine.winrm.map(winrm_vars).unwrap_or_default()
117                }
118            }),
119        }
120    }
121
122    /// External: each existing host's SSH user, port and key as its `external:` gives them;
123    /// Windows hosts over SSH with PowerShell (OpenSSH on Windows), not WinRM.
124    #[must_use]
125    pub fn external() -> Access<'static> {
126        Access {
127            linux: Vars::new(),
128            windows: vars(&WINDOWS_OVER_SSH),
129            host: Box::new(|h| external_vars(h.machine)),
130        }
131    }
132}
133
134/// A Windows host reached over OpenSSH, its commands run by PowerShell.
135const WINDOWS_OVER_SSH: [(&str, &str); 2] = [("ansible_connection", "ssh"), ("ansible_shell_type", "powershell")];
136
137/// How a Windows machine set up by Isoloom (on Proxmox and the clouds) is reached: as
138/// `isoloom` with a generated password, over WinRM on plain HTTP.
139fn isoloom_over_winrm(password: &str, transport: WinrmAuth) -> Vars {
140    let mut windows = vars(&[("ansible_user", "isoloom"), ("ansible_password", password), ("ansible_connection", "winrm")]);
141    windows.extend(match transport {
142        WinrmAuth::Basic => winrm_vars(Winrm::Plaintext),
143        WinrmAuth::Ntlm => vars(&[("ansible_port", "5985"), ("ansible_winrm_scheme", "http"), ("ansible_winrm_transport", "ntlm")]),
144    });
145    windows.extend(vars(&WINRM_PATIENCE));
146    windows
147}
148
149/// How WinRM authenticates over plain HTTP: Basic where the image allows it, NTLM where it
150/// doesn't (Azure's Windows images).
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum WinrmAuth {
153    /// Basic authentication (the user data allows it).
154    Basic,
155    /// NTLM.
156    Ntlm,
157}
158
159impl<'a> Access<'a> {
160    /// Proxmox: the `isoloom` user cloud-init creates on each Linux VM, with the controller's
161    /// key; on a Windows VM, the `isoloom` administrator its set-up makes, whose password is
162    /// `windows_password` (a Terraform expression, as the module writes it), over WinRM.
163    #[must_use]
164    pub fn proxmox(windows_password: &str) -> Access<'a> {
165        Access {
166            linux: vars(&[("ansible_ssh_private_key_file", CONTROLLER_KEY)]),
167            windows: isoloom_over_winrm(windows_password, WinrmAuth::Basic),
168            host: Box::new(|h| {
169                if h.machine.is_windows() {
170                    Vars::new()
171                } else {
172                    vars(&[("ansible_user", "isoloom")])
173                }
174            }),
175        }
176    }
177
178    /// A cloud: each Linux VM as its image's user (`linux_user` of its OS) with the
179    /// controller's key; Windows over plain-HTTP WinRM as `isoloom`, whose password is
180    /// `windows_password` (a Terraform expression, as the module writes it), authenticated as
181    /// the cloud's images allow (`auth`, as Terraform's own connection does).
182    #[must_use]
183    pub fn cloud(linux_user: impl Fn(&str) -> String + 'a, windows_password: &str, auth: WinrmAuth) -> Access<'a> {
184        Access {
185            linux: vars(&[("ansible_ssh_private_key_file", CONTROLLER_KEY)]),
186            windows: isoloom_over_winrm(windows_password, auth),
187            host: Box::new(move |h| match h.machine.os.as_deref() {
188                Some(os) if !h.machine.is_windows() => vec![("ansible_user".to_owned(), linux_user(os))],
189                _ => Vars::new(),
190            }),
191        }
192    }
193}
194
195/// An existing host's `ansible_user`, `ansible_port` and key: the ones its `external:` sets.
196fn external_vars(m: &MachinePlan) -> Vars {
197    let Some(e) = &m.external else { return Vars::new() };
198    let mut out = Vars::new();
199    if let Some(u) = &e.user {
200        out.push(("ansible_user".into(), u.clone()));
201    }
202    if let Some(p) = e.port {
203        out.push(("ansible_port".into(), p.to_string()));
204    }
205    if let Some(k) = &e.key {
206        out.push(("ansible_ssh_private_key_file".into(), k.clone()));
207    }
208    out
209}
210
211/// The machines Ansible reaches on the environment's target, in spec order: on `external`, the
212/// existing hosts (at their `external` address); elsewhere, the VMs (at their first address).
213#[must_use]
214pub fn hosts(env: &Environment) -> Vec<Host<'_>> {
215    env.machines
216        .iter()
217        .filter_map(|(name, m)| {
218            let address = if env.target == Target::External {
219                m.external.as_ref()?.address.clone()
220            } else if m.shape == MachineShape::Vm {
221                m.addresses.values().next()?.to_string()
222            } else {
223                return None;
224            };
225            Some(Host { name, address, machine: m })
226        })
227        .collect()
228}
229
230/// The inventory of `env`, its hosts reached as `access` says.
231#[must_use]
232pub fn render(env: &Environment, access: &Access<'_>) -> String {
233    let hosts = hosts(env);
234    let (windows, linux): (Vec<&Host>, Vec<&Host>) = hosts.iter().partition(|h| h.machine.is_windows());
235    let lines = |hs: &[&Host]| hs.iter().map(|h| host_line(h, access)).collect::<String>();
236    let windows_vars = if windows.is_empty() || access.windows.is_empty() {
237        String::new()
238    } else {
239        format!("\n[windows:vars]\n{}", group_vars(&access.windows))
240    };
241    let groups: String = env
242        .inventory_groups
243        .iter()
244        .map(|(group, members)| {
245            let here: Vec<&str> = members.iter().map(String::as_str).filter(|m| hosts.iter().any(|h| h.name == *m)).collect();
246            format!("\n[{group}]\n{}\n", here.join("\n"))
247        })
248        .collect::<Vec<_>>()
249        .concat();
250    format!(
251        "[linux]\n{}\n[windows]\n{}\n[linux:vars]\n{}ansible_become=true\n{windows_vars}{groups}",
252        lines(&linux),
253        lines(&windows),
254        group_vars(&access.linux)
255    )
256}
257
258/// `k=v` lines of a `[group:vars]` section (written as is: Ansible reads the rest of the line).
259fn group_vars(vars: &Vars) -> String {
260    vars.iter().map(|(k, v)| format!("{k}={v}\n")).collect::<Vec<_>>().concat()
261}
262
263/// A host's line: its name, `ansible_host`, the access's variables, then its `host_vars`.
264fn host_line(h: &Host<'_>, access: &Access<'_>) -> String {
265    let own: String = (access.host)(h)
266        .iter()
267        .map(|(k, v)| format!(" {k}={}", host_value(v)))
268        .collect::<Vec<_>>()
269        .concat();
270    format!(
271        "{} ansible_host={}{own}{}\n",
272        h.name,
273        host_value(&h.address),
274        host_vars_suffix(&h.machine.host_vars)
275    )
276}
277
278/// A value on a host line: Ansible splits the line like a shell, so a value with a space, a
279/// quote or a comment character is single-quoted; a plain one is written as is.
280#[must_use]
281pub fn host_value(v: &str) -> String {
282    let plain = !v.is_empty() && !v.chars().any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '\\' | '#' | ';'));
283    if plain { v.to_owned() } else { single_quoted(v) }
284}
285
286/// Single-quoted for Ansible's shell-like split of a host line.
287fn single_quoted(v: &str) -> String {
288    format!("'{}'", v.replace('\'', "'\"'\"'"))
289}
290
291/// A value as a Python literal, which is how Ansible reads a host line's values: a string
292/// quoted (so `"10"` stays a string), `True`, `False` and `None`, lists and dicts as Python
293/// spells them.
294#[must_use]
295pub fn python_literal(v: &serde_json::Value) -> String {
296    use serde_json::Value;
297    match v {
298        Value::Null => "None".into(),
299        Value::Bool(b) => if *b { "True" } else { "False" }.into(),
300        Value::Number(n) => n.to_string(),
301        Value::String(_) => v.to_string(),
302        Value::Array(a) => format!("[{}]", a.iter().map(python_literal).collect::<Vec<_>>().join(", ")),
303        Value::Object(o) => {
304            let items: Vec<String> = o
305                .iter()
306                .map(|(k, item)| format!("{}: {}", Value::String(k.clone()), python_literal(item)))
307                .collect();
308            format!("{{{}}}", items.join(", "))
309        }
310    }
311}
312
313/// The `host_vars` of one machine as ` key='value'` pairs, the way its inventory line ends.
314#[must_use]
315pub fn host_vars_suffix(host_vars: &IndexMap<String, serde_json::Value>) -> String {
316    host_vars
317        .iter()
318        .map(|(k, v)| format!(" {k}={}", single_quoted(&python_literal(v))))
319        .collect::<Vec<_>>()
320        .concat()
321}
322
323#[cfg(test)]
324mod tests;