use std::fmt;
use indexmap::IndexMap;
use isoloom_plan::{Environment, MachinePlan, MachineShape};
use isoloom_spec::model::{Target, Winrm};
pub type Vars = Vec<(String, String)>;
pub const CONTROLLER_KEY: &str = "/etc/isoloom/id_ed25519";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Host<'a> {
pub name: &'a str,
pub address: String,
pub machine: &'a MachinePlan,
}
#[non_exhaustive]
pub struct Access<'a> {
pub linux: Vars,
pub windows: Vars,
pub host: Box<dyn Fn(&Host<'_>) -> Vars + 'a>,
}
impl fmt::Debug for Access<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Access")
.field("linux", &self.linux)
.field("windows", &self.windows)
.finish_non_exhaustive()
}
}
fn vars(pairs: &[(&str, &str)]) -> Vars {
pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
}
const WINRM_PATIENCE: [(&str, &str); 3] = [
("ansible_winrm_server_cert_validation", "ignore"),
("ansible_winrm_operation_timeout_sec", "400"),
("ansible_winrm_read_timeout_sec", "500"),
];
#[must_use]
pub fn winrm_vars(winrm: Winrm) -> Vars {
match winrm {
Winrm::Ssl => vars(&[("ansible_port", "5986"), ("ansible_winrm_scheme", "https"), ("ansible_winrm_transport", "ntlm")]),
Winrm::Plaintext => vars(&[("ansible_port", "5985"), ("ansible_winrm_scheme", "http"), ("ansible_winrm_transport", "basic")]),
}
}
impl Access<'static> {
#[must_use]
pub fn vagrant() -> Access<'static> {
let account = [("ansible_user", "vagrant"), ("ansible_password", "vagrant")];
let mut windows = vars(&account);
windows.extend(vars(&[("ansible_connection", "winrm")]));
windows.extend(vars(&WINRM_PATIENCE));
Access {
linux: vars(&account),
windows,
host: Box::new(|h| {
if h.machine.winssh {
vars(&WINDOWS_OVER_SSH)
} else {
h.machine.winrm.map(winrm_vars).unwrap_or_default()
}
}),
}
}
#[must_use]
pub fn external() -> Access<'static> {
Access {
linux: Vars::new(),
windows: vars(&WINDOWS_OVER_SSH),
host: Box::new(|h| external_vars(h.machine)),
}
}
}
const WINDOWS_OVER_SSH: [(&str, &str); 2] = [("ansible_connection", "ssh"), ("ansible_shell_type", "powershell")];
fn isoloom_over_winrm(password: &str, transport: WinrmAuth) -> Vars {
let mut windows = vars(&[("ansible_user", "isoloom"), ("ansible_password", password), ("ansible_connection", "winrm")]);
windows.extend(match transport {
WinrmAuth::Basic => winrm_vars(Winrm::Plaintext),
WinrmAuth::Ntlm => vars(&[("ansible_port", "5985"), ("ansible_winrm_scheme", "http"), ("ansible_winrm_transport", "ntlm")]),
});
windows.extend(vars(&WINRM_PATIENCE));
windows
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WinrmAuth {
Basic,
Ntlm,
}
impl<'a> Access<'a> {
#[must_use]
pub fn proxmox(windows_password: &str) -> Access<'a> {
Access {
linux: vars(&[("ansible_ssh_private_key_file", CONTROLLER_KEY)]),
windows: isoloom_over_winrm(windows_password, WinrmAuth::Basic),
host: Box::new(|h| {
if h.machine.is_windows() {
Vars::new()
} else {
vars(&[("ansible_user", "isoloom")])
}
}),
}
}
#[must_use]
pub fn cloud(linux_user: impl Fn(&str) -> String + 'a, windows_password: &str, auth: WinrmAuth) -> Access<'a> {
Access {
linux: vars(&[("ansible_ssh_private_key_file", CONTROLLER_KEY)]),
windows: isoloom_over_winrm(windows_password, auth),
host: Box::new(move |h| match h.machine.os.as_deref() {
Some(os) if !h.machine.is_windows() => vec![("ansible_user".to_owned(), linux_user(os))],
_ => Vars::new(),
}),
}
}
}
fn external_vars(m: &MachinePlan) -> Vars {
let Some(e) = &m.external else { return Vars::new() };
let mut out = Vars::new();
if let Some(u) = &e.user {
out.push(("ansible_user".into(), u.clone()));
}
if let Some(p) = e.port {
out.push(("ansible_port".into(), p.to_string()));
}
if let Some(k) = &e.key {
out.push(("ansible_ssh_private_key_file".into(), k.clone()));
}
out
}
#[must_use]
pub fn hosts(env: &Environment) -> Vec<Host<'_>> {
env.machines
.iter()
.filter_map(|(name, m)| {
let address = if env.target == Target::External {
m.external.as_ref()?.address.clone()
} else if m.shape == MachineShape::Vm {
m.addresses.values().next()?.to_string()
} else {
return None;
};
Some(Host { name, address, machine: m })
})
.collect()
}
#[must_use]
pub fn render(env: &Environment, access: &Access<'_>) -> String {
let hosts = hosts(env);
let (windows, linux): (Vec<&Host>, Vec<&Host>) = hosts.iter().partition(|h| h.machine.is_windows());
let lines = |hs: &[&Host]| hs.iter().map(|h| host_line(h, access)).collect::<String>();
let windows_vars = if windows.is_empty() || access.windows.is_empty() {
String::new()
} else {
format!("\n[windows:vars]\n{}", group_vars(&access.windows))
};
let groups: String = env
.inventory_groups
.iter()
.map(|(group, members)| {
let here: Vec<&str> = members.iter().map(String::as_str).filter(|m| hosts.iter().any(|h| h.name == *m)).collect();
format!("\n[{group}]\n{}\n", here.join("\n"))
})
.collect::<Vec<_>>()
.concat();
format!(
"[linux]\n{}\n[windows]\n{}\n[linux:vars]\n{}ansible_become=true\n{windows_vars}{groups}",
lines(&linux),
lines(&windows),
group_vars(&access.linux)
)
}
fn group_vars(vars: &Vars) -> String {
vars.iter().map(|(k, v)| format!("{k}={v}\n")).collect::<Vec<_>>().concat()
}
fn host_line(h: &Host<'_>, access: &Access<'_>) -> String {
let own: String = (access.host)(h)
.iter()
.map(|(k, v)| format!(" {k}={}", host_value(v)))
.collect::<Vec<_>>()
.concat();
format!(
"{} ansible_host={}{own}{}\n",
h.name,
host_value(&h.address),
host_vars_suffix(&h.machine.host_vars)
)
}
#[must_use]
pub fn host_value(v: &str) -> String {
let plain = !v.is_empty() && !v.chars().any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '\\' | '#' | ';'));
if plain { v.to_owned() } else { single_quoted(v) }
}
fn single_quoted(v: &str) -> String {
format!("'{}'", v.replace('\'', "'\"'\"'"))
}
#[must_use]
pub fn python_literal(v: &serde_json::Value) -> String {
use serde_json::Value;
match v {
Value::Null => "None".into(),
Value::Bool(b) => if *b { "True" } else { "False" }.into(),
Value::Number(n) => n.to_string(),
Value::String(_) => v.to_string(),
Value::Array(a) => format!("[{}]", a.iter().map(python_literal).collect::<Vec<_>>().join(", ")),
Value::Object(o) => {
let items: Vec<String> = o
.iter()
.map(|(k, item)| format!("{}: {}", Value::String(k.clone()), python_literal(item)))
.collect();
format!("{{{}}}", items.join(", "))
}
}
}
#[must_use]
pub fn host_vars_suffix(host_vars: &IndexMap<String, serde_json::Value>) -> String {
host_vars
.iter()
.map(|(k, v)| format!(" {k}={}", single_quoted(&python_literal(v))))
.collect::<Vec<_>>()
.concat()
}
#[cfg(test)]
mod tests;