""" 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()