578 lines
20 KiB
Python
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()
|