homelab_automation/app/services/ansible_service.py

578 lines
20 KiB
Python

"""
Service de gestion d'Ansible (playbooks, inventaire, exécution).
"""
import asyncio
import os
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from time import perf_counter
from typing import Any, Dict, List, Optional
import yaml
from app.core.config import settings
from app.schemas.host_api import AnsibleInventoryHost
from app.schemas.ansible import PlaybookInfo
class AnsibleService:
"""Service pour gérer les playbooks et l'inventaire Ansible."""
def __init__(self, ansible_dir: Path = None, ssh_key_path: str = None, ssh_user: str = None):
self.ansible_dir = ansible_dir or settings.ansible_dir
self.playbooks_dir = self.ansible_dir / "playbooks"
self.inventory_path = self.ansible_dir / "inventory" / "hosts.yml"
self.ssh_key_path = ssh_key_path or settings.ssh_key_path
self.ssh_user = ssh_user or settings.ssh_user
# Cache
self._inventory_cache: Optional[Dict] = None
self._inventory_cache_time: float = 0
self._playbooks_cache: Optional[List[PlaybookInfo]] = None
self._playbooks_cache_time: float = 0
self._cache_ttl = settings.inventory_cache_ttl
def invalidate_cache(self):
"""Invalide les caches."""
self._inventory_cache = None
self._playbooks_cache = None
# ===== PLAYBOOKS =====
def get_playbooks(self) -> List[Dict[str, Any]]:
"""Récupère la liste des playbooks disponibles."""
import time
current_time = time.time()
if self._playbooks_cache and (current_time - self._playbooks_cache_time) < self._cache_ttl:
return self._playbooks_cache
playbooks = []
if not self.playbooks_dir.exists():
return playbooks
# Parcourir le répertoire principal
for item in self.playbooks_dir.iterdir():
if item.is_file() and item.suffix in ['.yml', '.yaml']:
pb = self._parse_playbook_file(item, "general", "other")
if pb:
playbooks.append(pb)
elif item.is_dir() and not item.name.startswith('.'):
# Sous-répertoire = catégorie
category = item.name
for subitem in item.iterdir():
if subitem.is_file() and subitem.suffix in ['.yml', '.yaml']:
pb = self._parse_playbook_file(subitem, category, "other")
if pb:
playbooks.append(pb)
elif subitem.is_dir() and not subitem.name.startswith('.'):
# Sous-sous-répertoire = subcategory
subcategory = subitem.name
for subsubitem in subitem.iterdir():
if subsubitem.is_file() and subsubitem.suffix in ['.yml', '.yaml']:
pb = self._parse_playbook_file(subsubitem, category, subcategory)
if pb:
playbooks.append(pb)
self._playbooks_cache = playbooks
self._playbooks_cache_time = current_time
return playbooks
def _parse_playbook_file(self, file_path: Path, category: str, subcategory: str) -> Optional[Dict[str, Any]]:
"""Parse un fichier playbook et extrait ses métadonnées."""
try:
stat = file_path.stat()
# Lire le contenu pour extraire hosts
hosts = "all"
description = None
try:
content = file_path.read_text(encoding='utf-8')
data = yaml.safe_load(content)
if isinstance(data, list) and len(data) > 0:
first_play = data[0]
if isinstance(first_play, dict):
hosts = first_play.get('hosts', 'all')
# Chercher une description dans les commentaires
if content.startswith('#'):
first_line = content.split('\n')[0]
description = first_line.lstrip('#').strip()
except Exception:
pass
return {
"name": file_path.stem,
"filename": file_path.name,
"path": str(file_path),
"category": category,
"subcategory": subcategory,
"hosts": hosts,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"description": description
}
except Exception:
return None
def get_playbook_categories(self) -> Dict[str, List[str]]:
"""Retourne les catégories de playbooks organisées."""
playbooks = self.get_playbooks()
categories = {}
for pb in playbooks:
cat = pb.get("category", "general")
subcat = pb.get("subcategory", "other")
if cat not in categories:
categories[cat] = []
if subcat not in categories[cat]:
categories[cat].append(subcat)
return categories
def is_target_compatible_with_playbook(self, target: str, playbook_hosts: str) -> bool:
"""Vérifie si une cible est compatible avec un playbook."""
# 'all' est toujours compatible
if playbook_hosts == 'all' or target == 'all':
return True
# Si le playbook cible exactement notre target
if playbook_hosts == target:
return True
# Vérifier si target fait partie des hosts du playbook
# Le playbook peut avoir une expression avec ":"
pb_hosts = [h.strip() for h in playbook_hosts.split(':')]
if target in pb_hosts:
return True
# Si le target est un groupe qui pourrait contenir les hosts du playbook
# Dans ce cas, on laisse passer car c'est géré par Ansible
groups = self.get_groups()
if target in groups:
return True
# Si le playbook cible un groupe spécifique et notre target est un hôte
hosts = self.get_hosts_from_inventory()
host_names = [h.name for h in hosts]
if target in host_names:
return True
return False
def get_compatible_playbooks(self, target: str) -> List[Dict[str, Any]]:
"""Retourne les playbooks compatibles avec une cible."""
all_playbooks = self.get_playbooks()
compatible = []
for pb in all_playbooks:
if self.is_target_compatible_with_playbook(target, pb.get('hosts', 'all')):
compatible.append(pb)
return compatible
# ===== INVENTAIRE =====
def load_inventory(self) -> Dict:
"""Charge l'inventaire Ansible depuis le fichier YAML."""
import time
current_time = time.time()
if self._inventory_cache and (current_time - self._inventory_cache_time) < self._cache_ttl:
return self._inventory_cache
if not self.inventory_path.exists():
return {}
try:
with open(self.inventory_path, 'r', encoding='utf-8') as f:
inventory = yaml.safe_load(f) or {}
self._inventory_cache = inventory
self._inventory_cache_time = current_time
return inventory
except Exception:
return {}
def _save_inventory(self, inventory: Dict):
"""Sauvegarde l'inventaire dans le fichier YAML."""
self.inventory_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.inventory_path, 'w', encoding='utf-8') as f:
yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True)
# Invalider le cache
self._inventory_cache = None
def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]:
"""Récupère les hôtes depuis l'inventaire Ansible."""
inventory = self.load_inventory()
# Dictionnaire pour collecter tous les groupes de chaque hôte
host_data: Dict[str, Dict] = {}
def extract_hosts(data: Dict, parent_group: str = None):
if not isinstance(data, dict):
return
for key, value in data.items():
if key == 'hosts' and isinstance(value, dict):
for host_name, host_vars in value.items():
if host_name not in host_data:
ansible_host = host_name
if isinstance(host_vars, dict):
ansible_host = host_vars.get('ansible_host', host_name)
host_data[host_name] = {
'ansible_host': ansible_host,
'groups': [],
'vars': host_vars if isinstance(host_vars, dict) else {}
}
# Ajouter ce groupe à la liste des groupes de l'hôte
if parent_group and parent_group not in host_data[host_name]['groups']:
host_data[host_name]['groups'].append(parent_group)
elif key == 'children' and isinstance(value, dict):
for child_group, child_data in value.items():
extract_hosts(child_data, child_group)
elif isinstance(value, dict) and key not in ['hosts', 'vars', 'children']:
extract_hosts(value, key)
extract_hosts(inventory)
# Convertir en liste d'objets AnsibleInventoryHost
hosts = []
for host_name, data in host_data.items():
# Filtrer par groupe si demandé
if group_filter and group_filter not in data['groups']:
continue
# Déterminer le groupe principal (premier groupe env_ ou premier groupe)
primary_group = "ungrouped"
for g in data['groups']:
if g.startswith('env_'):
primary_group = g
break
if primary_group == "ungrouped" and data['groups']:
primary_group = data['groups'][0]
hosts.append(AnsibleInventoryHost(
name=host_name,
ansible_host=data['ansible_host'],
group=primary_group,
groups=data['groups'],
vars=data['vars']
))
return hosts
def get_groups(self) -> List[str]:
"""Récupère la liste de tous les groupes."""
inventory = self.load_inventory()
groups = set()
def extract_groups(data: Dict):
if not isinstance(data, dict):
return
for key, value in data.items():
if key in ['hosts', 'vars']:
continue
if key == 'children' and isinstance(value, dict):
for child_group, child_data in value.items():
groups.add(child_group)
extract_groups(child_data)
elif isinstance(value, dict):
groups.add(key)
extract_groups(value)
extract_groups(inventory)
return sorted(list(groups))
def get_env_groups(self) -> List[str]:
"""Récupère les groupes d'environnement (préfixe env_)."""
return [g for g in self.get_groups() if g.startswith('env_')]
def get_role_groups(self) -> List[str]:
"""Récupère les groupes de rôles (préfixe role_)."""
return [g for g in self.get_groups() if g.startswith('role_')]
def host_exists(self, hostname: str) -> bool:
"""Vérifie si un hôte existe dans l'inventaire."""
hosts = self.get_hosts_from_inventory()
return any(h.name == hostname or h.ansible_host == hostname for h in hosts)
def group_exists(self, group_name: str) -> bool:
"""Vérifie si un groupe existe."""
return group_name in self.get_groups()
def add_host_to_inventory(
self,
hostname: str,
env_group: str,
role_groups: List[str] = None,
ansible_host: str = None
):
"""Ajoute un hôte à l'inventaire."""
inventory = self.load_inventory()
if 'all' not in inventory:
inventory['all'] = {'children': {}}
children = inventory['all'].setdefault('children', {})
# Ajouter au groupe d'environnement
if env_group not in children:
children[env_group] = {'hosts': {}}
env_data = children[env_group]
if 'hosts' not in env_data:
env_data['hosts'] = {}
host_vars = {}
if ansible_host and ansible_host != hostname:
host_vars['ansible_host'] = ansible_host
env_data['hosts'][hostname] = host_vars or None
# Ajouter aux groupes de rôles
for role in (role_groups or []):
if role not in children:
children[role] = {'hosts': {}}
role_data = children[role]
if 'hosts' not in role_data:
role_data['hosts'] = {}
role_data['hosts'][hostname] = None
self._save_inventory(inventory)
def remove_host_from_inventory(self, hostname: str):
"""Supprime un hôte de l'inventaire."""
inventory = self.load_inventory()
def remove_from_dict(data: Dict):
if not isinstance(data, dict):
return
if 'hosts' in data and isinstance(data['hosts'], dict):
data['hosts'].pop(hostname, None)
if 'children' in data and isinstance(data['children'], dict):
for child_data in data['children'].values():
remove_from_dict(child_data)
for key, value in list(data.items()):
if key not in ['hosts', 'vars', 'children'] and isinstance(value, dict):
remove_from_dict(value)
remove_from_dict(inventory)
self._save_inventory(inventory)
def update_host_groups(
self,
hostname: str,
env_group: str = None,
role_groups: List[str] = None,
ansible_host: str = None
):
"""Met à jour les groupes d'un hôte."""
# Supprimer l'hôte de tous les groupes
self.remove_host_from_inventory(hostname)
# Réajouter avec les nouveaux groupes
if env_group:
self.add_host_to_inventory(
hostname=hostname,
env_group=env_group,
role_groups=role_groups or [],
ansible_host=ansible_host
)
def add_group(self, group_name: str, group_type: str = "role"):
"""Ajoute un nouveau groupe."""
inventory = self.load_inventory()
if 'all' not in inventory:
inventory['all'] = {'children': {}}
children = inventory['all'].setdefault('children', {})
if group_name not in children:
children[group_name] = {'hosts': {}}
self._save_inventory(inventory)
def rename_group(self, old_name: str, new_name: str):
"""Renomme un groupe."""
inventory = self.load_inventory()
if 'all' not in inventory or 'children' not in inventory['all']:
return
children = inventory['all']['children']
if old_name in children:
children[new_name] = children.pop(old_name)
self._save_inventory(inventory)
def delete_group(self, group_name: str, move_hosts_to: str = None):
"""Supprime un groupe (optionnellement déplace les hôtes)."""
inventory = self.load_inventory()
if 'all' not in inventory or 'children' not in inventory['all']:
return
children = inventory['all']['children']
if group_name not in children:
return
# Récupérer les hôtes du groupe
group_hosts = []
if 'hosts' in children[group_name]:
group_hosts = list(children[group_name]['hosts'].keys())
# Déplacer les hôtes si demandé
if move_hosts_to and move_hosts_to in children:
target_group = children[move_hosts_to]
if 'hosts' not in target_group:
target_group['hosts'] = {}
for host in group_hosts:
target_group['hosts'][host] = children[group_name]['hosts'].get(host)
# Supprimer le groupe
del children[group_name]
self._save_inventory(inventory)
def get_group_hosts(self, group_name: str) -> List[str]:
"""Récupère les hôtes d'un groupe."""
hosts = self.get_hosts_from_inventory(group_filter=group_name)
return [h.name for h in hosts]
# ===== EXÉCUTION =====
async def execute_playbook(
self,
playbook: str,
target: str = "all",
extra_vars: Dict[str, Any] = None,
check_mode: bool = False,
verbose: bool = False
) -> Dict[str, Any]:
"""Exécute un playbook Ansible de manière asynchrone."""
start_time = perf_counter()
# Construire le chemin du playbook
if not playbook.endswith(('.yml', '.yaml')):
playbook = f"{playbook}.yml"
playbook_path = self._find_playbook_path(playbook)
if not playbook_path or not playbook_path.exists():
raise FileNotFoundError(f"Playbook non trouvé: {playbook}")
# Trouver la clé SSH
private_key = self._find_ssh_private_key()
# Construire la commande
cmd = [
"ansible-playbook",
str(playbook_path),
"-i", str(self.inventory_path),
"-l", target,
]
if check_mode:
cmd.append("--check")
if verbose:
cmd.append("-v")
if private_key:
cmd.extend(["--private-key", private_key])
if self.ssh_user:
cmd.extend(["-u", self.ssh_user])
if extra_vars:
import json
cmd.extend(["--extra-vars", json.dumps(extra_vars)])
# Exécuter la commande
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(self.ansible_dir)
)
stdout, stderr = await process.communicate()
execution_time = perf_counter() - start_time
return {
"success": process.returncode == 0,
"return_code": process.returncode,
"stdout": stdout.decode('utf-8', errors='replace'),
"stderr": stderr.decode('utf-8', errors='replace'),
"execution_time": execution_time,
"playbook": playbook,
"target": target,
"check_mode": check_mode
}
except FileNotFoundError:
return {
"success": False,
"return_code": -1,
"stdout": "",
"stderr": "ansible-playbook non trouvé. Vérifiez que Ansible est installé.",
"execution_time": perf_counter() - start_time,
"playbook": playbook,
"target": target,
"check_mode": check_mode
}
def _find_playbook_path(self, playbook: str) -> Optional[Path]:
"""Trouve le chemin complet d'un playbook."""
# Chemin direct
direct_path = self.playbooks_dir / playbook
if direct_path.exists():
return direct_path
# Chercher dans les sous-répertoires
for item in self.playbooks_dir.rglob(playbook):
if item.is_file():
return item
return None
def _find_ssh_private_key(self) -> Optional[str]:
"""Trouve une clé SSH privée valide."""
# Essayer le chemin configuré
if self.ssh_key_path:
key_path = Path(self.ssh_key_path)
if key_path.exists():
return str(key_path)
# Chercher dans les emplacements standard
candidates = [
Path.home() / ".ssh" / "id_rsa",
Path.home() / ".ssh" / "id_ed25519",
Path.home() / ".ssh" / "id_ecdsa",
Path("/app/docker/ssh_keys/id_automation_ansible"),
]
for candidate in candidates:
if candidate.exists():
return str(candidate)
return None
# Instance singleton du service
ansible_service = AnsibleService()