1use std::fmt;
34
35use indexmap::IndexMap;
36
37use isoloom_plan::{Environment, MachinePlan, MachineShape};
38use isoloom_spec::model::{Target, Winrm};
39
40pub type Vars = Vec<(String, String)>;
42
43pub const CONTROLLER_KEY: &str = "/etc/isoloom/id_ed25519";
45
46#[derive(Debug, Clone)]
48#[non_exhaustive]
49pub struct Host<'a> {
50 pub name: &'a str,
52 pub address: String,
54 pub machine: &'a MachinePlan,
56}
57
58#[non_exhaustive]
60pub struct Access<'a> {
61 pub linux: Vars,
63 pub windows: Vars,
65 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
82const 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#[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 #[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 #[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
134const WINDOWS_OVER_SSH: [(&str, &str); 2] = [("ansible_connection", "ssh"), ("ansible_shell_type", "powershell")];
136
137fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum WinrmAuth {
153 Basic,
155 Ntlm,
157}
158
159impl<'a> Access<'a> {
160 #[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 #[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
195fn 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#[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#[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
258fn group_vars(vars: &Vars) -> String {
260 vars.iter().map(|(k, v)| format!("{k}={v}\n")).collect::<Vec<_>>().concat()
261}
262
263fn 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#[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
286fn single_quoted(v: &str) -> String {
288 format!("'{}'", v.replace('\'', "'\"'\"'"))
289}
290
291#[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#[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;