use crate::output;
use crate::user_config::UserConfig;
use eyre::{Result, WrapErr};
use std::io::Write;
use std::path::Path;
use std::process::Command;
pub struct AnsibleResult {
pub success: bool,
pub exit_code: i32,
pub last_output: String,
}
pub struct InventoryHost {
pub name: String,
pub address: String,
pub port: u16,
pub user: String,
}
fn write_extra_vars_file() -> Result<tempfile::NamedTempFile> {
let config = UserConfig::load()?;
let flat = config.flatten_for_ansible();
let yaml = serde_yaml::to_string(&flat).wrap_err("Failed to serialize config to YAML")?;
let mut tmpfile = tempfile::NamedTempFile::new().wrap_err("Failed to create temp file")?;
tmpfile
.write_all(yaml.as_bytes())
.wrap_err("Failed to write extra-vars file")?;
Ok(tmpfile)
}
fn write_inventory_file(host: &InventoryHost) -> Result<tempfile::NamedTempFile> {
use serde_yaml::{Mapping, Value};
let mut host_vars = Mapping::new();
host_vars.insert(
Value::String("ansible_host".into()),
Value::String(host.address.clone()),
);
host_vars.insert(
Value::String("ansible_port".into()),
Value::Number(host.port.into()),
);
let mut hosts = Mapping::new();
hosts.insert(Value::String(host.name.clone()), Value::Mapping(host_vars));
let mut vps = Mapping::new();
vps.insert(Value::String("hosts".into()), Value::Mapping(hosts));
let mut children = Mapping::new();
children.insert(Value::String("vps".into()), Value::Mapping(vps));
let mut all = Mapping::new();
all.insert(Value::String("children".into()), Value::Mapping(children));
let mut root = Mapping::new();
root.insert(Value::String("all".into()), Value::Mapping(all));
let yaml =
serde_yaml::to_string(&Value::Mapping(root)).wrap_err("Failed to serialize inventory")?;
let mut tmpfile = tempfile::NamedTempFile::new().wrap_err("Failed to create temp file")?;
tmpfile
.write_all(yaml.as_bytes())
.wrap_err("Failed to write inventory file")?;
Ok(tmpfile)
}
fn tag_required_keys(tag: &str) -> &[&'static str] {
match tag {
"colporteur" => &["colporteur_subdomain"],
"hermes" => &["hermes_openrouter_api_key", "hermes_telegram_bot_token"],
"tgtg" => &["tgtg_telegram_bot_token"],
_ => &[],
}
}
pub fn required_config_keys(playbook_name: &str, tags: Option<&[String]>) -> Vec<&'static str> {
let mut keys: Vec<&'static str> = Vec::new();
match playbook_name {
"bootstrap.yml" => {
keys.extend(["admin_user_name", "ssh_port", "hostname"]);
}
"hardening.yml" => {}
"infrastructure.yml" => {
keys.extend(["admin_user_name", "domain", "tailscale_authkey"]);
}
"apps.yml" => {
keys.extend(["admin_user_name", "domain", "cloudflare_dns_api_token"]);
}
"hermes.yml" => {
keys.extend([
"admin_user_name",
"domain",
"hermes_openrouter_api_key",
"hermes_telegram_bot_token",
]);
}
_ => {
keys.extend(["admin_user_name", "domain"]);
}
}
if playbook_name == "apps.yml"
&& let Some(tags) = tags
{
for tag in tags {
for key in tag_required_keys(tag) {
if !keys.contains(key) {
keys.push(key);
}
}
}
}
keys
}
pub fn run_playbook(
playbook: &Path,
host: &InventoryHost,
check: bool,
tags: Option<&[String]>,
skip_tags: Option<&[String]>,
extra_vars: Option<&[(&str, &str)]>,
ask_vault_pass: bool,
ask_pass: bool,
) -> Result<AnsibleResult> {
let assets = crate::ansible_assets::AnsibleAssets::prepare()?;
assets.ensure_collections()?;
let ansible_dir = assets.ansible_dir().to_path_buf();
let vars_file = write_extra_vars_file()?;
let inventory_file = write_inventory_file(host)?;
let mut cmd = Command::new("ansible-playbook");
cmd.current_dir(&ansible_dir)
.arg("-i")
.arg("inventory.yml")
.arg("-i")
.arg(inventory_file.path())
.arg(playbook.strip_prefix(&ansible_dir).unwrap_or(playbook))
.arg("--limit")
.arg(&host.name)
.arg("--extra-vars")
.arg(format!("@{}", vars_file.path().display()));
let playbook_name = playbook.file_name().and_then(|n| n.to_str()).unwrap_or("");
let is_fresh_bootstrap = playbook_name == "bootstrap.yml";
if check {
cmd.arg("--check");
}
if ask_vault_pass {
cmd.arg("--ask-vault-pass");
}
if is_fresh_bootstrap {
cmd.arg("--ask-pass");
cmd.arg("-e").arg(format!("ansible_port={}", host.port));
cmd.arg("-e").arg(format!("ansible_user={}", host.user));
cmd.arg("-e").arg(
"ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'",
);
}
if ask_pass && !is_fresh_bootstrap {
cmd.arg("--ask-pass");
}
if let Some(tags) = tags {
cmd.arg("--tags").arg(tags.join(","));
}
if let Some(skip_tags) = skip_tags {
cmd.arg("--skip-tags").arg(skip_tags.join(","));
}
if let Some(vars) = extra_vars {
for (key, value) in vars {
cmd.arg("-e").arg(format!("{}={}", key, value));
}
}
let needs_tty = ask_vault_pass || ask_pass || is_fresh_bootstrap;
if needs_tty {
let status = cmd
.status()
.wrap_err("Failed to execute ansible-playbook")?;
return Ok(AnsibleResult {
success: status.success(),
exit_code: status.code().unwrap_or(-1),
last_output: String::new(),
});
}
let playbook_label = playbook
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("ansible");
let spinner = output::spinner(&format!("Running {}...", playbook_label));
let result = output::run_with_stdout_progress("ansible", &mut cmd, &spinner, |line, pb| {
if let Some(task) = output::parse_ansible_task(line) {
pb.set_message(format!("Running: {}", output::format_ansible_task(&task)));
}
})
.wrap_err("Failed to execute ansible-playbook")?;
spinner.finish_and_clear();
Ok(AnsibleResult {
success: result.status.success(),
exit_code: result.status.code().unwrap_or(-1),
last_output: result.last_stderr,
})
}
pub fn run_bootstrap(playbook: &Path, host: &InventoryHost) -> Result<AnsibleResult> {
let assets = crate::ansible_assets::AnsibleAssets::prepare()?;
assets.ensure_collections()?;
let ansible_dir = assets.ansible_dir().to_path_buf();
let vars_file = write_extra_vars_file()?;
let inventory_file = write_inventory_file(host)?;
let status = Command::new("ansible-playbook")
.current_dir(&ansible_dir)
.arg("-i")
.arg("inventory.yml")
.arg("-i")
.arg(inventory_file.path())
.arg(playbook.strip_prefix(&ansible_dir).unwrap_or(playbook))
.arg("--limit")
.arg(&host.name)
.arg("--extra-vars")
.arg(format!("@{}", vars_file.path().display()))
.arg("-e")
.arg(format!("ansible_user={}", host.user))
.arg("-e")
.arg(format!("ansible_port={}", host.port))
.arg("--ask-pass")
.status()
.wrap_err("Failed to execute ansible-playbook")?;
Ok(AnsibleResult {
success: status.success(),
exit_code: status.code().unwrap_or(-1),
last_output: String::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_required_config_keys_bootstrap() {
let keys = required_config_keys("bootstrap.yml", None);
assert!(keys.contains(&"admin_user_name"));
assert!(keys.contains(&"ssh_port"));
assert!(keys.contains(&"hostname"));
}
#[test]
fn test_required_config_keys_infrastructure() {
let keys = required_config_keys("infrastructure.yml", None);
assert!(keys.contains(&"admin_user_name"));
assert!(keys.contains(&"domain"));
assert!(keys.contains(&"tailscale_authkey"));
}
#[test]
fn test_required_config_keys_apps() {
let keys = required_config_keys("apps.yml", None);
assert!(keys.contains(&"cloudflare_dns_api_token"));
assert!(!keys.contains(&"colporteur_subdomain"));
}
#[test]
fn test_required_config_keys_apps_with_colporteur_tag() {
let tags = vec!["colporteur".to_string()];
let keys = required_config_keys("apps.yml", Some(&tags));
assert!(keys.contains(&"cloudflare_dns_api_token"));
assert!(keys.contains(&"colporteur_subdomain"));
}
#[test]
fn test_required_config_keys_apps_with_hermes_tag() {
let tags = vec!["hermes".to_string()];
let keys = required_config_keys("apps.yml", Some(&tags));
assert!(keys.contains(&"cloudflare_dns_api_token"));
assert!(keys.contains(&"hermes_openrouter_api_key"));
assert!(keys.contains(&"hermes_telegram_bot_token"));
}
#[test]
fn test_required_config_keys_apps_with_tgtg_tag() {
let tags = vec!["tgtg".to_string()];
let keys = required_config_keys("apps.yml", Some(&tags));
assert!(keys.contains(&"cloudflare_dns_api_token"));
assert!(keys.contains(&"tgtg_telegram_bot_token"));
}
#[test]
fn test_required_config_keys_apps_with_unrelated_tag() {
let tags = vec!["paperless".to_string()];
let keys = required_config_keys("apps.yml", Some(&tags));
assert!(!keys.contains(&"colporteur_subdomain"));
}
#[test]
fn test_required_config_keys_ignores_tags_for_non_apps_playbooks() {
let tags = vec!["colporteur".to_string()];
let keys = required_config_keys("infrastructure.yml", Some(&tags));
assert!(!keys.contains(&"colporteur_subdomain"));
}
#[test]
fn test_required_config_keys_hardening_is_empty() {
let keys = required_config_keys("hardening.yml", None);
assert!(keys.is_empty());
}
#[test]
fn test_required_config_keys_hermes() {
let keys = required_config_keys("hermes.yml", None);
assert!(keys.contains(&"admin_user_name"));
assert!(keys.contains(&"domain"));
assert!(keys.contains(&"hermes_openrouter_api_key"));
assert!(keys.contains(&"hermes_telegram_bot_token"));
}
#[test]
fn test_required_config_keys_unknown_playbook_returns_defaults() {
let keys = required_config_keys("custom.yml", None);
assert!(keys.contains(&"admin_user_name"));
assert!(keys.contains(&"domain"));
}
#[test]
fn test_write_inventory_file_generates_valid_yaml() {
let host = InventoryHost {
name: "testhost".to_string(),
address: "198.51.100.1".to_string(),
port: 59865,
user: "root".to_string(),
};
let tmpfile = write_inventory_file(&host).unwrap();
let contents = std::fs::read_to_string(tmpfile.path()).unwrap();
let parsed: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap();
let host_entry = &parsed["all"]["children"]["vps"]["hosts"]["testhost"];
assert_eq!(host_entry["ansible_host"].as_str().unwrap(), "198.51.100.1");
assert_eq!(host_entry["ansible_port"].as_u64().unwrap(), 59865);
}
#[test]
fn test_write_inventory_file_places_host_in_vps_group() {
let host = InventoryHost {
name: "myserver".to_string(),
address: "203.0.113.42".to_string(),
port: 22,
user: "debian".to_string(),
};
let tmpfile = write_inventory_file(&host).unwrap();
let contents = std::fs::read_to_string(tmpfile.path()).unwrap();
let parsed: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap();
assert!(parsed["all"]["children"]["vps"]["hosts"]["myserver"].is_mapping());
}
#[test]
fn test_write_inventory_file_escapes_special_chars() {
let host = InventoryHost {
name: "host:with#special".to_string(),
address: "198.51.100.1".to_string(),
port: 22,
user: "root".to_string(),
};
let tmpfile = write_inventory_file(&host).unwrap();
let contents = std::fs::read_to_string(tmpfile.path()).unwrap();
let parsed: serde_yaml::Value = serde_yaml::from_str(&contents).unwrap();
let host_entry = &parsed["all"]["children"]["vps"]["hosts"]["host:with#special"];
assert_eq!(host_entry["ansible_host"].as_str().unwrap(), "198.51.100.1");
}
}