diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index c387873..0000000 --- a/.coveragerc +++ /dev/null @@ -1,3 +0,0 @@ -[run] -omit = - tests/* \ No newline at end of file diff --git a/README.md b/README.md index cbbfabe..994d669 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,68 @@ Exemple minimal `.env` : ```dotenv GITHUB_TOKEN=ghp_................................ ``` + +### Webhooks (notifications) + +GitHub Runner Manager supporte l'envoi de notifications via webhooks pour vous tenir informé des événements importants comme le démarrage/arrêt des runners, la construction d'images, ou les mises à jour disponibles. + +Pour configurer les webhooks, ajoutez une section `webhooks` dans votre `runners_config.yaml` : + +```yaml +webhooks: + enabled: true + timeout: 10 + retry_count: 3 + retry_delay: 5 + + # Configuration pour Slack + slack: + enabled: true + webhook_url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" + username: "GitHub Runner Bot" + events: + - runner_started + - runner_error + - build_completed + - update_available +``` + +Les événements supportés incluent : +- `runner_started` : Quand un runner est démarré +- `runner_stopped` : Quand un runner est arrêté +- `runner_removed` : Quand un runner est supprimé +- `runner_error` : En cas d'erreur avec un runner +- `runner_skipped` : Quand une action sur un runner est ignorée (ex: arrêt d'un runner qui n'est pas démarré) +- `build_started` : Quand la construction d'une image démarre +- `build_completed` : Quand la construction d'une image est terminée +- `build_failed` : Quand la construction d'une image échoue +- `image_updated` : Quand une image est mise à jour +- `update_available` : Quand une mise à jour est disponible +- `update_applied` : Quand une mise à jour est appliquée +- `update_error` : En cas d'erreur lors d'une mise à jour + +Plusieurs providers de webhooks sont supportés : +- Slack +- Discord +- Microsoft Teams +- Webhooks génériques + +Pour des exemples de configuration complets, consultez : +```bash +cp runners_config.yaml.webhook-example runners_config.yaml +``` + +#### Tester les webhooks + +Pour tester vos webhooks sans déclencher d'actions réelles : + +```bash +# Tester un événement spécifique +python main.py webhook test --event runner_started --provider slack + +# Tester tous les événements configurés +python main.py webhook test-all --provider slack +``` Un fichier d'exemple `runners_config.yaml.dist` est fourni à la racine du projet. Copiez-le pour créer votre propre configuration : ```bash diff --git a/runners_config.yaml.dist b/runners_config.yaml.dist index 31efd07..53cd3c7 100644 --- a/runners_config.yaml.dist +++ b/runners_config.yaml.dist @@ -27,5 +27,151 @@ scheduler: actions: - check - build - - deploy - max_retries: 3 \ No newline at end of file + - deploy + max_retries: 3 + + +webhooks: + # Configuration générale des webhooks + enabled: true + timeout: 10 # Délai d'attente en secondes global + retry_count: 3 + retry_delay: 5 # Secondes + + # Configuration spécifique pour Slack + slack: + enabled: true + webhook_url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" + username: "GitHub Runner Bot" + timeout: 10 # Délai d'attente spécifique à Slack + + # Liste des événements à notifier + events: + - runner_started + - runner_stopped + - runner_error + - build_started + - build_completed + - build_failed + - update_available + - update_applied + + # Templates de messages pour différents événements + templates: + # Template par défaut (utilisé si pas de template spécifique) + default: + title: "Notification GitHub Runner Manager" + text: "Un événement s'est produit" + color: "#36a64f" # Vert + use_attachment: true + fields: [] + + # Runner démarré + runner_started: + title: "Runner démarré" + text: "Le runner *{runner_name}* a été démarré avec succès" + color: "#36a64f" # Vert + fields: + - name: "Runner ID" + value: "{runner_id}" + short: true + - name: "Labels" + value: "{labels}" + short: true + - name: "Technologie" + value: "{techno} {techno_version}" + short: true + + # Runner arrêté + runner_stopped: + title: "Runner arrêté" + text: "Le runner *{runner_name}* a été arrêté" + color: "#ff9000" # Orange + fields: + - name: "Runner ID" + value: "{runner_id}" + short: true + - name: "Temps de fonctionnement" + value: "{uptime}" + short: true + + # Erreur runner + runner_error: + title: "⚠️ Erreur Runner" + text: "Une erreur s'est produite avec le runner *{runner_name}*" + color: "#ff0000" # Rouge + fields: + - name: "Runner ID" + value: "{runner_id}" + short: true + - name: "Erreur" + value: "```{error_message}```" + short: false + + # Build commencé + build_started: + title: "Build démarré" + text: "Construction de l'image *{image_name}* démarrée" + color: "#3AA3E3" # Bleu + fields: + - name: "Base image" + value: "{base_image}" + short: true + - name: "Technologie" + value: "{techno} {techno_version}" + short: true + + # Build terminé + build_completed: + title: "Build terminé" + text: "Construction de l'image *{image_name}* terminée avec succès en {duration}s" + color: "#36a64f" # Vert + fields: + - name: "Image" + value: "{image_name}" + short: true + - name: "Taille" + value: "{image_size}" + short: true + + # Build échoué + build_failed: + title: "⚠️ Build échoué" + text: "La construction de l'image *{image_name}* a échoué" + color: "#ff0000" # Rouge + fields: + - name: "Erreur" + value: "```{error_message}```" + short: false + + # Mise à jour disponible + update_available: + title: "Mise à jour disponible" + text: "Une mise à jour est disponible pour l'image *{image_name}*" + color: "#3AA3E3" # Bleu + fields: + - name: "Version actuelle" + value: "{current_version}" + short: true + - name: "Nouvelle version" + value: "{new_version}" + short: true + - name: "Auto-update" + value: "{auto_update}" + short: true + + # Mise à jour appliquée + update_applied: + title: "Mise à jour appliquée" + text: "L'image *{image_name}* a été mise à jour avec succès" + color: "#36a64f" # Vert + fields: + - name: "Ancienne version" + value: "{old_version}" + short: true + - name: "Nouvelle version" + value: "{new_version}" + short: true + - name: "Runners impactés" + value: "{affected_runners}" + short: false \ No newline at end of file diff --git a/src/notifications/channels/base.py b/src/notifications/channels/base.py new file mode 100644 index 0000000..dedfb2a --- /dev/null +++ b/src/notifications/channels/base.py @@ -0,0 +1,33 @@ +"""Canaux de notifications (interface + registry).""" + +from __future__ import annotations + +from typing import List, Protocol + +from ..events import NotificationEvent + + +class NotificationChannel(Protocol): + name: str + + def supports( + self, event: NotificationEvent + ) -> bool: # pragma: no cover (interface) + ... + + def send(self, event: NotificationEvent) -> None: # pragma: no cover (interface) + ... + + +_registry: List[NotificationChannel] = [] + + +def register(channel: NotificationChannel) -> None: + _registry.append(channel) + + +def channels() -> List[NotificationChannel]: + return list(_registry) + + +__all__ = ["NotificationChannel", "register", "channels"] diff --git a/src/notifications/channels/webhook.py b/src/notifications/channels/webhook.py new file mode 100644 index 0000000..88f7f5f --- /dev/null +++ b/src/notifications/channels/webhook.py @@ -0,0 +1,39 @@ +"""Canal de notification via le service Webhook existant.""" + +from __future__ import annotations + +from src.services.webhook_service import WebhookService + +from ..events import NotificationEvent +from .base import NotificationChannel, register + + +class WebhookChannel: + name = "webhook" + + def __init__(self, webhook_service: WebhookService): + self._svc = webhook_service + + # Tous les événements sont supportés, filtrage déjà assuré côté WebhookService via config + def supports(self, event: NotificationEvent) -> bool: # pragma: no cover - trivial + return True + + def send(self, event: NotificationEvent) -> None: + payload = event.to_payload() + event_type = payload.pop("event_type") + # Compat: ne pas inclure timestamp ni valeurs None pour ne pas casser anciens tests + payload.pop("timestamp", None) + compact = {k: v for k, v in payload.items() if v is not None} + # Compat héritage: retirer restarted si False + if compact.get("restarted") is False: + compact.pop("restarted") + self._svc.notify(event_type, compact) + + +def build_and_register(webhook_service: WebhookService) -> NotificationChannel: + channel = WebhookChannel(webhook_service) + register(channel) + return channel + + +__all__ = ["WebhookChannel", "build_and_register"] diff --git a/src/notifications/dispatcher.py b/src/notifications/dispatcher.py new file mode 100644 index 0000000..a9510eb --- /dev/null +++ b/src/notifications/dispatcher.py @@ -0,0 +1,22 @@ +"""Dispatcher qui route les événements vers les canaux enregistrés.""" + +from __future__ import annotations + +from typing import Iterable + +from .channels.base import channels +from .events import NotificationEvent + + +class NotificationDispatcher: + def dispatch(self, event: NotificationEvent) -> None: + for ch in channels(): + if ch.supports(event): + ch.send(event) + + def dispatch_many(self, events: Iterable[NotificationEvent]) -> None: + for e in events: + self.dispatch(e) + + +__all__ = ["NotificationDispatcher"] diff --git a/src/notifications/events.py b/src/notifications/events.py new file mode 100644 index 0000000..650c5b1 --- /dev/null +++ b/src/notifications/events.py @@ -0,0 +1,163 @@ +"""Définition des événements de notification typés. + +Chaque événement est une dataclass immuable afin de faciliter tests, sérialisation +et extension. La méthode ``event_type`` fournit la clé utilisée par les canaux. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Any, Dict, List + +# Base ----------------------------------------------------------------------- + + +@dataclass(frozen=True) +class NotificationEvent: + # Le timestamp est kw_only pour ne pas imposer d'ordre dans les sous-classes + timestamp: datetime = field(default_factory=lambda: datetime.now(), kw_only=True) + + def event_type(self) -> str: # snake_case pour cohérence existante + # Convertit CamelCase -> snake_case simple + name = self.__class__.__name__ + out = [] + for i, c in enumerate(name): + if c.isupper() and i and (not name[i - 1].isupper()): + out.append("_") + out.append(c.lower()) + return "".join(out) + + def to_payload(self) -> Dict[str, Any]: # utilisé par canaux génériques + data = asdict(self) + data["event_type"] = self.event_type() + return data + + +# Runner Events -------------------------------------------------------------- + + +@dataclass(frozen=True) +class RunnerStarted(NotificationEvent): + runner_id: str + runner_name: str + labels: List[str] | str | None = None + techno: str | None = None + techno_version: str | None = None + restarted: bool = False + + +@dataclass(frozen=True) +class RunnerStopped(NotificationEvent): + runner_id: str + runner_name: str + uptime: str | None = None + + +@dataclass(frozen=True) +class RunnerRemoved(NotificationEvent): + runner_id: str + runner_name: str + + +@dataclass(frozen=True) +class RunnerError(NotificationEvent): + runner_id: str + runner_name: str + error_message: str + + +@dataclass(frozen=True) +class RunnerSkipped(NotificationEvent): + runner_name: str + operation: str + reason: str + + +# Build / Image Events ------------------------------------------------------- + + +@dataclass(frozen=True) +class BuildStarted(NotificationEvent): + image_name: str + dockerfile: str | None = None + id: str | None = None + + +@dataclass(frozen=True) +class BuildCompleted(NotificationEvent): + image_name: str + dockerfile: str | None = None + id: str | None = None + + +@dataclass(frozen=True) +class BuildFailed(NotificationEvent): + id: str | None + error_message: str + + +@dataclass(frozen=True) +class ImageUpdated(NotificationEvent): + runner_type: str + from_version: str + to_version: str + image_name: str | None = None + + +@dataclass(frozen=True) +class UpdateAvailable(NotificationEvent): + runner_type: str + current_version: str + available_version: str + + +@dataclass(frozen=True) +class UpdateApplied(NotificationEvent): + runner_type: str + from_version: str + to_version: str + image_name: str | None = None + + +@dataclass(frozen=True) +class UpdateError(NotificationEvent): + runner_type: str + error_message: str + + +# Factory mapping utilitaire (option public simple) -------------------------- +EVENT_NAME_TO_CLASS = { + "runner_started": RunnerStarted, + "runner_stopped": RunnerStopped, + "runner_removed": RunnerRemoved, + "runner_error": RunnerError, + "runner_skipped": RunnerSkipped, + "build_started": BuildStarted, + "build_completed": BuildCompleted, + "build_failed": BuildFailed, + "image_updated": ImageUpdated, + "update_available": UpdateAvailable, + "update_applied": UpdateApplied, + "update_error": UpdateError, +} + +__all__ = [ + "NotificationEvent", + # Runner events + "RunnerStarted", + "RunnerStopped", + "RunnerRemoved", + "RunnerError", + "RunnerSkipped", + # Build / image events + "BuildStarted", + "BuildCompleted", + "BuildFailed", + "ImageUpdated", + "UpdateAvailable", + "UpdateApplied", + "UpdateError", + # Mapping + "EVENT_NAME_TO_CLASS", +] diff --git a/src/notifications/factory.py b/src/notifications/factory.py new file mode 100644 index 0000000..bdcfd81 --- /dev/null +++ b/src/notifications/factory.py @@ -0,0 +1,126 @@ +"""Factory utilitaire pour convertir les résultats d'opérations en événements typés.""" + +from __future__ import annotations + +from typing import Any, Dict + +from .events import ( + BuildCompleted, + BuildFailed, + ImageUpdated, + RunnerError, + RunnerRemoved, + RunnerSkipped, + RunnerStarted, + RunnerStopped, + UpdateAvailable, + UpdateError, +) + +# NOTE: On garde uniquement les événements réellement utilisés dans notify_from_docker_result. + + +def events_from_operation(operation: str, result: Dict[str, Any]): + yield from _iter_events(operation, result) + + +def _iter_events(operation: str, result: Dict[str, Any]): + if operation == "build": + for built in result.get("built", []): + yield BuildCompleted( + image_name=built.get("image", ""), + dockerfile=built.get("dockerfile", ""), + id=built.get("id", ""), + ) + for error in result.get("errors", []): + yield BuildFailed( + id=error.get("id", ""), + error_message=error.get("reason", "Unknown error"), + ) + + elif operation == "start": + for started in result.get("started", []): + yield RunnerStarted( + runner_id=started.get("id", ""), + runner_name=started.get("name", ""), + labels=started.get("labels", []), + techno=started.get("techno", ""), + techno_version=started.get("techno_version", ""), + ) + for restarted in result.get("restarted", []): + yield RunnerStarted( + runner_id=restarted.get("id", ""), + runner_name=restarted.get("name", ""), + labels=restarted.get("labels", []), + techno=restarted.get("techno", ""), + techno_version=restarted.get("techno_version", ""), + restarted=True, + ) + for error in result.get("errors", []): + yield RunnerError( + runner_id=error.get("id", ""), + runner_name=error.get("name", error.get("id", "")), + error_message=error.get("reason", "Unknown error"), + ) + + elif operation == "stop": + for stopped in result.get("stopped", []): + yield RunnerStopped( + runner_id=stopped.get("id", ""), + runner_name=stopped.get("name", ""), + uptime=stopped.get("uptime", "unknown"), + ) + for error in result.get("errors", []): + yield RunnerError( + runner_id=error.get("id", ""), + runner_name=error.get("name", ""), + error_message=error.get("reason", "Unknown error"), + ) + for skipped in result.get("skipped", []): + yield RunnerSkipped( + runner_name=skipped.get("name", ""), + operation="stop", + reason="Runner not running", + ) + + elif operation == "remove": + for deleted in result.get("deleted", []): + yield RunnerRemoved( + runner_id=deleted.get("id", ""), + runner_name=deleted.get("name", ""), + ) + for error in result.get("errors", []): + yield RunnerError( + runner_id=error.get("id", ""), + runner_name=error.get("name", ""), + error_message=error.get("reason", "Unknown error"), + ) + for skipped in result.get("skipped", []): + yield RunnerSkipped( + runner_name=skipped.get("name", ""), + operation="remove", + reason=skipped.get("reason", result.get("reason", "Unknown reason")), + ) + + elif operation == "update": + if result.get("update_available"): + yield UpdateAvailable( + runner_type="base", + current_version=result.get("current_version", ""), + available_version=result.get("latest_version", ""), + ) + if result.get("updated"): + yield ImageUpdated( + runner_type="base", + from_version=result.get("old_version", ""), + to_version=result.get("new_version", ""), + image_name=result.get("new_image", ""), + ) + if result.get("error"): + yield UpdateError( + runner_type="base", + error_message=result.get("error", "Unknown error"), + ) + + +__all__ = ["events_from_operation"] diff --git a/src/presentation/cli/commands.py b/src/presentation/cli/commands.py index fff2217..10428b0 100644 --- a/src/presentation/cli/commands.py +++ b/src/presentation/cli/commands.py @@ -5,19 +5,29 @@ import typer from rich.console import Console +# Importer les commandes webhook +from src.presentation.cli.webhook_commands import ( + debug_test_all_templates, + test_webhooks, +) from src.services import ConfigService, DockerService +from src.services.notification_service import NotificationService from src.services.scheduler_service import SchedulerService config_service = ConfigService() docker_service = DockerService(config_service) console = Console() scheduler_service = SchedulerService(config_service, docker_service, console) +notification_service = NotificationService(config_service, console) app = typer.Typer( help="GitHub Runner Manager - Gérez vos GitHub Actions runners Docker" ) console = Console() +# Sous-commande pour les webhooks +webhook_app = typer.Typer(help="Commandes pour tester et déboguer les webhooks") + @app.command() def build_runners_images(quiet: bool = False, progress: bool = True) -> None: @@ -25,8 +35,12 @@ def build_runners_images(quiet: bool = False, progress: bool = True) -> None: --quiet : réduit la verbosité du build en affichant uniquement les étapes et erreurs. """ + # (Notification de début de build supprimée) + + # Exécuter le build result = docker_service.build_runner_images(quiet=quiet, use_progress=progress) + # Afficher les résultats for built in result.get("built", []): console.print( f"[green][SUCCESS] Image {built['image']} buildée depuis {built['dockerfile']}[/green]" @@ -40,6 +54,9 @@ def build_runners_images(quiet: bool = False, progress: bool = True) -> None: for error in result.get("errors", []): console.print(f"[red][ERREUR] {error['id']}: {error['reason']}[/red]") + # Envoyer les notifications du résultat + notification_service.notify_from_docker_result("build", result) + @app.command() def start_runners() -> None: @@ -51,11 +68,34 @@ def start_runners() -> None: f"[green][INFO] Runner {started['name']} démarré avec succès.[/green]" ) + # Notification de démarrage d'un runner + notification_service.notify_runner_started( + { + "runner_id": started.get("id", ""), + "runner_name": started.get("name", ""), + "labels": started.get("labels", ""), + "techno": started.get("techno", ""), + "techno_version": started.get("techno_version", ""), + } + ) + for restarted in result.get("restarted", []): console.print( f"[yellow][INFO] Runner {restarted['name']} existant mais stoppé. Redémarrage...[/yellow]" ) + # Notification de redémarrage d'un runner + notification_service.notify_runner_started( + { + "runner_id": restarted.get("id", ""), + "runner_name": restarted.get("name", ""), + "labels": restarted.get("labels", ""), + "techno": restarted.get("techno", ""), + "techno_version": restarted.get("techno_version", ""), + "restarted": True, + } + ) + for running in result.get("running", []): console.print( f"[yellow][INFO] Runner {running['name']} déjà démarré. Rien à faire.[/yellow]" @@ -69,6 +109,15 @@ def start_runners() -> None: for error in result.get("errors", []): console.print(f"[red][ERREUR] {error['id']}: {error['reason']}[/red]") + # Notification d'erreur de démarrage d'un runner + notification_service.notify_runner_error( + { + "runner_id": error.get("id", ""), + "runner_name": error.get("name", error.get("id", "")), + "error_message": error.get("reason", "Unknown error"), + } + ) + @app.command() def stop_runners() -> None: @@ -80,6 +129,15 @@ def stop_runners() -> None: f"[green][INFO] Runner {stopped['name']} arrêté avec succès.[/green]" ) + # Notification d'arrêt de runner + notification_service.notify_runner_stopped( + { + "runner_id": stopped.get("id", ""), + "runner_name": stopped.get("name", ""), + "uptime": stopped.get("uptime", "unknown"), + } + ) + for skipped in result.get("skipped", []): console.print( f"[yellow][INFO] {skipped['name']} n'est pas en cours d'exécution.[/yellow]" @@ -88,26 +146,61 @@ def stop_runners() -> None: for error in result.get("errors", []): console.print(f"[red][ERREUR] {error['name']}: {error['reason']}[/red]") + # Notification d'erreur d'arrêt de runner + notification_service.notify_runner_error( + { + "runner_id": error.get("id", ""), + "runner_name": error.get("name", ""), + "error_message": error.get("reason", "Unknown error"), + } + ) + @app.command() def remove_runners() -> None: - """Désenregistre les runners (config.sh remove) et supprime le container et le dossier runner.""" + """Désenregistre et supprime les runners Docker selon la configuration YAML.""" result = docker_service.remove_runners() + # Ancienne clé 'deleted' + for deleted in result.get("deleted", []): + name = deleted.get("name") or deleted.get("id") or "?" + console.print(f"[green][INFO] Runner {name} supprimé avec succès.[/green]") + notification_service.notify_runner_removed( + {"runner_id": deleted.get("id", name), "runner_name": name} + ) + # Nouvelle clé 'removed' mais respecter les tests: n'afficher le succès + # que si l'entrée possède 'container'. Les entrées avec uniquement 'name' + # ne doivent pas produire le message (test attendu). for removed in result.get("removed", []): if "container" in removed: - console.print( - f"[green][INFO] Container {removed['container']} supprimé avec succès.[/green]" + name = removed.get("container") + console.print(f"[green][INFO] Runner {name} supprimé avec succès.[/green]") + notification_service.notify_runner_removed( + {"runner_id": removed.get("id", name), "runner_name": name} ) for skipped in result.get("skipped", []): - console.print( - f"[yellow][INFO] {skipped['name']} : {skipped['reason']}.[/yellow]" - ) + reason = skipped.get("reason") + name = skipped.get("name", "?") + if reason: + console.print(f"[yellow][INFO] {name} {reason}.[/yellow]") + else: + console.print( + f"[yellow][INFO] {name} n'est pas disponible à la suppression.[/yellow]" + ) for error in result.get("errors", []): console.print(f"[red][ERREUR] {error['name']}: {error['reason']}[/red]") + # Notification d'erreur de suppression de runner + notification_service.notify_runner_error( + { + "runner_id": error.get("id", ""), + "runner_name": error.get("name", ""), + "error_message": error.get("reason", "Unknown error"), + } + ) + @app.command() def check_base_image_update() -> None: @@ -117,6 +210,14 @@ def check_base_image_update() -> None: if result.get("error"): console.print(f"[red]{result['error']}[/red]") + + # Notification d'erreur de vérification + notification_service.notify_update_error( + { + "runner_type": "base", + "error_message": result.get("error", "Unknown error"), + } + ) return if not result.get("update_available"): @@ -130,6 +231,15 @@ def check_base_image_update() -> None: f"(actuelle : {result['current_version']})[/yellow]" ) + # Notification de mise à jour disponible + notification_service.notify_update_available( + { + "runner_type": "base", + "current_version": result.get("current_version", "unknown"), + "available_version": result.get("latest_version", "unknown"), + } + ) + if typer.confirm( f"Mettre à jour base_image vers la version {result['latest_version']} dans runners_config.yaml ?" ): @@ -139,10 +249,29 @@ def check_base_image_update() -> None: console.print( f"[red]Erreur lors de la mise à jour: {update_result['error']}[/red]" ) + + # Notification d'erreur de mise à jour + notification_service.notify_update_error( + { + "runner_type": "base", + "error_message": update_result.get("error", "Unknown error"), + } + ) elif update_result.get("updated"): console.print( f"[green]base_image mis à jour vers {update_result['new_image']} dans runners_config.yaml[/green]" ) + + # Notification de mise à jour d'image + notification_service.notify_image_updated( + { + "runner_type": "base", + "from_version": result.get("current_version", "unknown"), + "to_version": result.get("latest_version", "unknown"), + "image_name": update_result.get("new_image", ""), + } + ) + # Proposer de builder les images avec cette nouvelle base if typer.confirm( f"Voulez-vous builder les images des runners avec la nouvelle image {update_result.get('new_image')} ?" @@ -167,6 +296,9 @@ def check_base_image_update() -> None: f"[red][ERREUR] {error['id']}: {error['reason']}[/red]" ) + # Notification des résultats du build + notification_service.notify_from_docker_result("build", build_result) + # Proposer de déployer les nouveaux containers si des images ont été buildées if build_result.get("built"): if typer.confirm( @@ -177,24 +309,66 @@ def check_base_image_update() -> None: console.print( f"[green][INFO] Runner {started['name']} démarré avec succès.[/green]" ) + + # Notification de démarrage d'un runner + notification_service.notify_runner_started( + { + "runner_id": started.get("id", ""), + "runner_name": started.get("name", ""), + "labels": started.get("labels", ""), + "techno": started.get("techno", ""), + "techno_version": started.get("techno_version", ""), + } + ) + for restarted in start_result.get("restarted", []): console.print( f"[yellow][INFO] Runner {restarted['name']} existant mais stoppé." f" Redémarrage...[/yellow]" ) + + # Notification de redémarrage d'un runner + notification_service.notify_runner_started( + { + "runner_id": restarted.get("id", ""), + "runner_name": restarted.get("name", ""), + "labels": restarted.get("labels", ""), + "techno": restarted.get("techno", ""), + "techno_version": restarted.get( + "techno_version", "" + ), + "restarted": True, + } + ) + for running in start_result.get("running", []): console.print( f"[yellow][INFO] Runner {running['name']} déjà démarré. Rien à faire.[/yellow]" ) + for removed in start_result.get("removed", []): console.print( f"[yellow][INFO] Container {removed['name']} n'est plus requis " f"et a été supprimé.[/yellow]" ) + for error in start_result.get("errors", []): console.print( f"[red][ERREUR] {error['id']}: {error['reason']}[/red]" ) + + # Notification d'erreur de démarrage d'un runner + notification_service.notify_runner_error( + { + "runner_id": error.get("id", ""), + "runner_name": error.get( + "name", error.get("id", "") + ), + "error_message": error.get( + "reason", "Unknown error" + ), + } + ) else: console.print("[yellow]Mise à jour annulée.[/yellow]") @@ -287,5 +461,45 @@ def scheduler() -> None: console.print(f"[red]Erreur dans le scheduler: {str(e)}[/red]") +@webhook_app.command("test") +def webhook_test( + event_type: str = typer.Option( + None, "--event", "-e", help="Type d'événement à simuler" + ), + provider: str = typer.Option( + None, "--provider", "-p", help="Provider webhook spécifique à utiliser" + ), +) -> None: + """ + Teste l'envoi d'une notification webhook avec des données simulées. + + Si aucun type d'événement n'est spécifié, un menu interactif sera affiché. + Si aucun provider n'est spécifié, tous les providers configurés seront utilisés. + """ + test_webhooks( + config_service, event_type, provider, interactive=True, console=console + ) + + +@webhook_app.command("test-all") +def webhook_test_all( + provider: str = typer.Option( + None, "--provider", "-p", help="Provider webhook spécifique à tester" + ) +) -> None: + """ + Teste tous les templates webhook configurés. + + Envoie une notification pour chaque type d'événement configuré, + pour le provider spécifié ou pour tous les providers. + """ + debug_test_all_templates(config_service, provider, console=console) + + +# Ajouter le sous-groupe webhook +app.add_typer( + webhook_app, name="webhook", help="Commandes pour tester et déboguer les webhooks" +) + if __name__ == "__main__": # pragma: no cover app() diff --git a/src/presentation/cli/webhook_commands.py b/src/presentation/cli/webhook_commands.py new file mode 100644 index 0000000..e4ff362 --- /dev/null +++ b/src/presentation/cli/webhook_commands.py @@ -0,0 +1,281 @@ +"""Commande de débogage et test des webhooks.""" + +import datetime +import json +from enum import Enum +from typing import Any, Dict, Optional + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Prompt + +from src.services import ConfigService +from src.services.webhook_service import WebhookService + + +class MockEvent(str, Enum): + """Types d'événements pour les simulations de tests webhook.""" + + RUNNER_STARTED = "runner_started" + RUNNER_STOPPED = "runner_stopped" + RUNNER_ERROR = "runner_error" + BUILD_STARTED = "build_started" + BUILD_COMPLETED = "build_completed" + BUILD_FAILED = "build_failed" + UPDATE_AVAILABLE = "update_available" + UPDATE_APPLIED = "update_applied" + + +# Données de simulation pour chaque type d'événement +MOCK_DATA = { + MockEvent.RUNNER_STARTED: { + "runner_id": "php83-1", + "runner_name": "itroom-runner-php83-1", + "labels": "itroom-runner-set-php83, php8.3", + "techno": "php", + "techno_version": "8.3", + }, + MockEvent.RUNNER_STOPPED: { + "runner_id": "php83-1", + "runner_name": "itroom-runner-php83-1", + "uptime": "3h 24m 12s", + }, + MockEvent.RUNNER_ERROR: { + "runner_id": "php83-1", + "runner_name": "itroom-runner-php83-1", + "error_message": "Le runner n'a pas pu s'enregistrer auprès de GitHub: token invalide.", + }, + MockEvent.BUILD_STARTED: { + "image_name": "itroom-runner-php83", + "base_image": "ghcr.io/actions/actions-runner:2.328.0", + "techno": "php", + "techno_version": "8.3", + }, + MockEvent.BUILD_COMPLETED: { + "image_name": "itroom-runner-php83", + "duration": "45", + "image_size": "1.2GB", + }, + MockEvent.BUILD_FAILED: { + "image_name": "itroom-runner-php83", + "error_message": "Erreur lors de l'étape 3/8: npm install a échoué avec le code 1", + }, + MockEvent.UPDATE_AVAILABLE: { + "image_name": "actions-runner", + "current_version": "2.328.0", + "new_version": "2.329.0", + "auto_update": "Activé", + }, + MockEvent.UPDATE_APPLIED: { + "image_name": "actions-runner", + "old_version": "2.328.0", + "new_version": "2.329.0", + "affected_runners": "php83-1, node20-1", + }, +} + + +def test_webhooks( + config_service: ConfigService, + event_type: Optional[str] = None, + provider: Optional[str] = None, + interactive: bool = True, + console: Optional[Console] = None, +) -> Dict[str, Any]: + """ + Teste l'envoi de webhooks avec des données simulées. + + Args: + config_service: Service de configuration + event_type: Type d'événement à simuler (si None, un menu sera affiché) + provider: Fournisseur de webhook spécifique à utiliser + (Si None, tous les fournisseurs configurés seront utilisés) + interactive: Mode interactif avec confirmation et affichage détaillé + console: Console Rich pour l'affichage + + Returns: + Résultat de l'opération avec statuts pour chaque provider + """ + console = console or Console() + config = config_service.load_config() + + if not hasattr(config, "webhooks") or not config.webhooks: + console.print( + "[red]Aucune configuration webhook trouvée dans runners_config.yaml[/red]" + ) + return {"error": "Aucune configuration webhook trouvée"} + + # Initialiser le service webhook + webhook_service = WebhookService(config.webhooks.dict(), console) + + # Si aucun provider n'est initialisé + if not webhook_service.providers: + console.print( + "[red]Aucun provider webhook n'est activé dans la configuration[/red]" + ) + return {"error": "Aucun provider activé"} + + # Liste des providers disponibles + available_providers = list(webhook_service.providers.keys()) + + # Afficher les providers disponibles + if interactive: + console.print("[green]Providers webhook disponibles:[/green]") + for provider_name in available_providers: + console.print(f" - {provider_name}") + + # Si aucun event_type n'est fourni, afficher un menu interactif + if not event_type and interactive: + event_choices = [e.value for e in MockEvent] + event_type = Prompt.ask( + "Choisissez un type d'événement à simuler", + choices=event_choices, + default=MockEvent.RUNNER_STARTED, + ) + elif not event_type: + event_type = MockEvent.RUNNER_STARTED + + # Vérifier si l'event_type est valide + if event_type not in [e.value for e in MockEvent]: + console.print(f"[red]Type d'événement '{event_type}' non valide[/red]") + return {"error": f"Type d'événement '{event_type}' non valide"} + + # Si aucun provider n'est fourni et qu'on est en mode interactif, demander + if not provider and interactive and len(available_providers) > 1: + provider = Prompt.ask( + "Choisissez un provider spécifique (laisser vide pour tous)", + choices=available_providers + [""], + default="", + ) + + # Récupérer les données simulées pour cet événement + mock_data = MOCK_DATA.get(event_type, {}).copy() + + # Ajouter un timestamp + mock_data["timestamp"] = datetime.datetime.now().isoformat() + + # Afficher un aperçu des données + if interactive: + console.print("\n[yellow]Données de simulation qui seront envoyées:[/yellow]") + console.print(Panel(json.dumps(mock_data, indent=2), title="Données")) + + # Demander confirmation + if not typer.confirm("Envoyer cette notification webhook?"): + console.print("[yellow]Envoi annulé[/yellow]") + return {"cancelled": True} + + # Envoyer la notification + results = webhook_service.notify( + event_type, mock_data, provider if provider else None + ) + + # Afficher les résultats + if interactive: + console.print("\n[bold]Résultats de l'envoi:[/bold]") + for provider_name, success in results.items(): + if success: + console.print( + f"[green]✅ {provider_name}: Notification envoyée avec succès[/green]" + ) + else: + console.print(f"[red]❌ {provider_name}: Échec de l'envoi[/red]") + + return { + "event_type": event_type, + "provider": provider if provider else "all", + "data": mock_data, + "results": results, + } + + +def debug_test_all_templates( + config_service: ConfigService, + provider: Optional[str] = None, + console: Optional[Console] = None, +) -> Dict[str, Any]: + """ + Teste tous les templates configurés pour un provider ou tous les providers. + + Args: + config_service: Service de configuration + provider: Provider spécifique à tester (si None, tous les providers seront testés) + console: Console Rich pour l'affichage + + Returns: + Résultats des tests pour chaque template + """ + console = console or Console() + config = config_service.load_config() + + if not hasattr(config, "webhooks") or not config.webhooks: + console.print( + "[red]Aucune configuration webhook trouvée dans runners_config.yaml[/red]" + ) + return {"error": "Aucune configuration webhook trouvée"} + + # Initialiser le service webhook + webhook_service = WebhookService(config.webhooks.dict(), console) + + # Si aucun provider n'est initialisé + if not webhook_service.providers: + console.print( + "[red]Aucun provider webhook n'est activé dans la configuration[/red]" + ) + return {"error": "Aucun provider activé"} + + # Liste des providers à tester + providers_to_test = ( + [provider] if provider else list(webhook_service.providers.keys()) + ) + + results = {} + + # Pour chaque provider + for provider_name in providers_to_test: + if provider_name not in webhook_service.providers: + console.print( + f"[yellow]Provider '{provider_name}' non configuré, ignoré[/yellow]" + ) + continue + + provider_config = webhook_service.providers[provider_name] + provider_events = provider_config.get("events", []) + + console.print( + f"\n[bold blue]Test des templates pour {provider_name}:[/bold blue]" + ) + + provider_results = {} + + # Pour chaque type d'événement disponible + for event_type in [e.value for e in MockEvent]: + # Si cet événement est configuré pour ce provider + if event_type in provider_events: + console.print(f"\n[yellow]Test du template '{event_type}':[/yellow]") + + # Récupérer les données simulées + mock_data = MOCK_DATA.get(event_type, {}).copy() + mock_data["timestamp"] = datetime.datetime.now().isoformat() + + # Envoyer la notification + success = webhook_service._send_notification( + provider_name, event_type, mock_data, provider_config + ) + + provider_results[event_type] = success + + if success: + console.print( + f"[green]✅ {event_type}: Notification envoyée avec succès[/green]" + ) + else: + console.print(f"[red]❌ {event_type}: Échec de l'envoi[/red]") + else: + console.print( + f"[dim]Template '{event_type}' non configuré pour {provider_name}, ignoré[/dim]" + ) + + results[provider_name] = provider_results + + return results diff --git a/src/services/config_schema.py b/src/services/config_schema.py index e919444..449aa74 100644 --- a/src/services/config_schema.py +++ b/src/services/config_schema.py @@ -1,6 +1,6 @@ -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, HttpUrl class RunnerConfig(BaseModel): @@ -23,6 +23,91 @@ class WebhookConfig(BaseModel): events: List[str] = [] +# Définitions communes pour les champs de notification +class NotificationField(BaseModel): + """Configuration d'un champ dans une notification""" + + name: str + value: str + short: bool = True # Pour Slack + inline: bool = True # Pour Discord + + +# Templates pour Slack +class SlackTemplateConfig(BaseModel): + """Template pour une notification Slack""" + + title: str + text: str + color: str = "#36a64f" # Vert par défaut + use_attachment: bool = True + fields: List[NotificationField] = [] + + +# Templates pour Discord +class DiscordTemplateConfig(BaseModel): + """Template pour une notification Discord""" + + title: str + description: str + color: int = 3066993 # Vert en décimal + fields: List[NotificationField] = [] + + +# Section pour Teams +class TeamsSection(BaseModel): + """Section dans une carte Microsoft Teams""" + + activityTitle: str + facts: List[Dict[str, str]] = [] + + +# Templates pour Microsoft Teams +class TeamsTemplateConfig(BaseModel): + """Template pour une notification Microsoft Teams""" + + title: str + themeColor: str = "0076D7" # Bleu par défaut + sections: List[TeamsSection] = [] + + +# Configuration Slack +class SlackConfig(BaseModel): + """Configuration des notifications Slack""" + + enabled: bool = False + webhook_url: HttpUrl + channel: str = "" # Optionnel, peut être défini dans l'URL du webhook + username: str = "GitHub Runner Manager" + timeout: int = 10 + events: List[str] = [] + templates: Dict[str, SlackTemplateConfig] = {} + + +# Configuration Discord +class DiscordConfig(BaseModel): + """Configuration des notifications Discord""" + + enabled: bool = False + webhook_url: HttpUrl + username: str = "GitHub Runner Manager" + avatar_url: Optional[HttpUrl] = None + timeout: int = 10 + events: List[str] = [] + templates: Dict[str, DiscordTemplateConfig] = {} + + +# Configuration Microsoft Teams +class TeamsConfig(BaseModel): + """Configuration des notifications Microsoft Teams""" + + enabled: bool = False + webhook_url: HttpUrl + timeout: int = 10 + events: List[str] = [] + templates: Dict[str, TeamsTemplateConfig] = {} + + class SchedulerConfig(BaseModel): enabled: bool = False check_interval: str = "15s" @@ -34,7 +119,21 @@ class SchedulerConfig(BaseModel): webhook: Optional[WebhookConfig] = None +# Configuration globale des webhooks +class WebhooksConfig(BaseModel): + """Configuration globale des webhooks""" + + enabled: bool = False + timeout: int = 10 + retry_count: int = 3 + retry_delay: int = 5 + slack: Optional[SlackConfig] = None + discord: Optional[DiscordConfig] = None + teams: Optional[TeamsConfig] = None + + class FullConfig(BaseModel): runners_defaults: RunnersDefaults runners: List[RunnerConfig] scheduler: Optional[SchedulerConfig] = None + webhooks: Optional[WebhooksConfig] = None diff --git a/src/services/docker_service.py b/src/services/docker_service.py index 01e3214..2f00716 100644 --- a/src/services/docker_service.py +++ b/src/services/docker_service.py @@ -423,7 +423,7 @@ def start_runners(self) -> dict: idx = int(parts[-1]) if idx > nb: try: - if self.container_running(name): + if not self.container_running(name): self.start_container(name) self.exec_command( name, diff --git a/src/services/notification_service.py b/src/services/notification_service.py new file mode 100644 index 0000000..095abcf --- /dev/null +++ b/src/services/notification_service.py @@ -0,0 +1,192 @@ +"""Service de notification refactorisé avec événements et dispatcher. + +Compatibilité maintenue: les anciennes méthodes ``notify_*`` existent toujours +et délèguent à l'API événementielle interne. +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable, Optional + +from rich.console import Console + +from src.notifications.channels.webhook import build_and_register +from src.notifications.dispatcher import NotificationDispatcher +from src.notifications.events import UpdateApplied # peut être utilisé si besoin futur +from src.notifications.events import ( + BuildCompleted, + BuildFailed, + ImageUpdated, + RunnerError, + RunnerRemoved, + RunnerStarted, + RunnerStopped, + UpdateAvailable, + UpdateError, +) +from src.notifications.factory import events_from_operation +from src.services.config_service import ConfigService +from src.services.webhook_service import WebhookService + + +class NotificationService: + """Façade publique stable pour l'envoi de notifications. + + Internellement repose sur un dispatcher + événements typés. + """ + + def __init__( + self, config_service: ConfigService, console: Optional[Console] = None + ): + self.config_service = config_service + self.console = console or Console() + self.webhook_service: WebhookService | None = None + self.dispatcher = NotificationDispatcher() + + config = self.config_service.load_config() + if hasattr(config, "webhooks") and config.webhooks: + self.webhook_service = WebhookService( + config.webhooks.model_dump(), self.console + ) + if self.webhook_service.enabled: + build_and_register(self.webhook_service) + + # --- Nouvelles primitives internes ---------------------------------- + def _emit(self, events: Iterable): # events: Iterable[NotificationEvent] + if not self.webhook_service or not self.webhook_service.enabled: + return + self.dispatcher.dispatch_many(events) + + # --- Méthodes compatibles (gardées pour code existant/tests) --------- + def notify_runner_started(self, runner_data: Dict[str, Any]) -> None: + self._emit( + [ + RunnerStarted( + runner_id=runner_data.get("runner_id", runner_data.get("id", "")), + runner_name=runner_data.get( + "runner_name", runner_data.get("name", "") + ), + labels=runner_data.get("labels"), + techno=runner_data.get("techno"), + techno_version=runner_data.get("techno_version"), + restarted=runner_data.get("restarted", False), + ) + ] + ) + + def notify_runner_stopped(self, runner_data: Dict[str, Any]) -> None: + self._emit( + [ + RunnerStopped( + runner_id=runner_data.get("runner_id", runner_data.get("id", "")), + runner_name=runner_data.get( + "runner_name", runner_data.get("name", "") + ), + uptime=runner_data.get("uptime"), + ) + ] + ) + + def notify_runner_removed(self, runner_data: Dict[str, Any]) -> None: + self._emit( + [ + RunnerRemoved( + runner_id=runner_data.get("runner_id", runner_data.get("id", "")), + runner_name=runner_data.get( + "runner_name", runner_data.get("name", "") + ), + ) + ] + ) + + def notify_runner_error(self, runner_data: Dict[str, Any]) -> None: + self._emit( + [ + RunnerError( + runner_id=runner_data.get("runner_id", runner_data.get("id", "")), + runner_name=runner_data.get( + "runner_name", runner_data.get("name", "") + ), + error_message=runner_data.get("error_message", "Unknown error"), + ) + ] + ) + + def notify_build_completed(self, build_data: Dict[str, Any]) -> None: + self._emit( + [ + BuildCompleted( + image_name=build_data.get( + "image_name", build_data.get("image", "") + ), + dockerfile=build_data.get("dockerfile"), + id=build_data.get("id"), + ) + ] + ) + + def notify_build_failed(self, build_data: Dict[str, Any]) -> None: + self._emit( + [ + BuildFailed( + id=build_data.get("id"), + error_message=build_data.get("error_message", "Unknown error"), + ) + ] + ) + + def notify_image_updated(self, update_data: Dict[str, Any]) -> None: + self._emit( + [ + ImageUpdated( + runner_type=update_data.get("runner_type", "base"), + from_version=update_data.get("from_version", ""), + to_version=update_data.get("to_version", ""), + image_name=update_data.get("image_name"), + ) + ] + ) + + def notify_update_available(self, update_data: Dict[str, Any]) -> None: + self._emit( + [ + UpdateAvailable( + runner_type=update_data.get("runner_type", "base"), + current_version=update_data.get("current_version", ""), + available_version=update_data.get( + "available_version", update_data.get("latest_version", "") + ), + ) + ] + ) + + def notify_update_applied(self, update_data: Dict[str, Any]) -> None: + self._emit( + [ + UpdateApplied( + runner_type=update_data.get("runner_type", "base"), + from_version=update_data.get("from_version", ""), + to_version=update_data.get("to_version", ""), + image_name=update_data.get("image_name"), + ) + ] + ) + + def notify_update_error(self, update_data: Dict[str, Any]) -> None: + self._emit( + [ + UpdateError( + runner_type=update_data.get("runner_type", "base"), + error_message=update_data.get("error_message", "Unknown error"), + ) + ] + ) + + # --- Nouvelle API pour résultats docker ------------------------------ + def notify_from_docker_result(self, operation: str, result: Dict[str, Any]) -> None: + if not self.webhook_service or not self.webhook_service.enabled: + return + self._emit(events_from_operation(operation, result)) + + +__all__ = ["NotificationService"] diff --git a/src/services/webhook_service.py b/src/services/webhook_service.py new file mode 100644 index 0000000..cf9f43f --- /dev/null +++ b/src/services/webhook_service.py @@ -0,0 +1,459 @@ +""" +Service de gestion des webhooks pour GitHub Runner Manager. +Ce service fournit une interface unifiée pour envoyer des notifications vers différents +services comme Slack, Discord, Microsoft Teams, etc. +""" + +import logging +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Optional + +import requests +from rich.console import Console + +logger = logging.getLogger(__name__) + + +class WebhookProvider(Enum): + """Types de fournisseurs de webhook supportés""" + + SLACK = "slack" + DISCORD = "discord" + TEAMS = "teams" + GENERIC = "generic" + + def __str__(self) -> str: + return self.value + + +class WebhookService: + """Service unifié pour la gestion des webhooks sortants.""" + + def __init__(self, config: Dict[str, Any], console: Optional[Console] = None): + """ + Initialise le service de webhooks. + + Args: + config: Configuration des webhooks (section 'webhooks' du fichier de config) + console: Console Rich pour l'affichage (optionnel) + """ + self.config = config or {} + self.console = console or Console() + self.enabled = self.config.get("enabled", False) + self.timeout = self.config.get("timeout", 10) + self.retry_count = self.config.get("retry_count", 3) + self.retry_delay = self.config.get("retry_delay", 5) + + # Initialisation des providers + self.providers = {} + + # Si le service est activé, initialiser les providers configurés + if self.enabled: + self._init_providers() + + def _init_providers(self): + """Initialise les providers de webhook configurés.""" + # Parcourir les fournisseurs connus + for provider_name in WebhookProvider: + provider_config = self.config.get(provider_name.value) + + # Si le fournisseur est configuré et activé + if provider_config and provider_config.get("enabled", False): + self.console.print( + f"[green]Initialisation du provider webhook [bold]{provider_name.value}[/bold][/green]" + ) + + # Stocker la configuration du provider + self.providers[provider_name.value] = provider_config + + def notify( + self, event_type: str, data: Dict[str, Any], provider: Optional[str] = None + ) -> Dict[str, bool]: + """ + Envoie une notification à tous les providers configurés pour cet événement. + + Args: + event_type: Type d'événement à notifier (runner_started, build_failed, etc.) + data: Données à inclure dans la notification + provider: Provider spécifique à utiliser (optionnel) + + Returns: + Dictionnaire avec les providers comme clés et les statuts comme valeurs + """ + if not self.enabled: + logger.info("Service webhook désactivé, notification ignorée") + return {} + + results = {} + + # Filtrer les providers à utiliser + providers_to_use = {} + if provider: + if provider in self.providers: + providers_to_use = {provider: self.providers[provider]} + else: + self.console.print( + f"[yellow]Provider webhook [bold]{provider}[/bold] non configuré[/yellow]" + ) + return {} + else: + providers_to_use = self.providers + + # Pour chaque provider configuré + for provider_name, provider_config in providers_to_use.items(): + # Vérifier si cet événement est configuré pour ce provider + if event_type in provider_config.get("events", []): + # Envoyer la notification + success = self._send_notification( + provider_name, event_type, data, provider_config + ) + results[provider_name] = success + + if success: + self.console.print( + f"[green]Notification [bold]{event_type}[/bold] " + f"envoyée via [bold]{provider_name}[/bold][/green]" + ) + else: + self.console.print( + f"[red]Échec de l'envoi de la notification [bold]" + f"{event_type}[/bold] via [bold]{provider_name}[/bold][/red]" + ) + + return results + + def _send_notification( + self, + provider: str, + event_type: str, + data: Dict[str, Any], + config: Dict[str, Any], + ) -> bool: + """ + Envoie une notification à un provider spécifique. + + Args: + provider: Nom du provider (slack, discord, teams, etc.) + event_type: Type d'événement à notifier + data: Données à inclure dans la notification + config: Configuration du provider + + Returns: + True si l'envoi a réussi, False sinon + """ + try: + webhook_url = config.get("webhook_url") + if not webhook_url: + logger.error(f"URL webhook manquante pour le provider {provider}") + return False + + # Formatage spécifique au provider + payload = None + if provider == WebhookProvider.SLACK.value: + payload = self._format_slack_payload(event_type, data, config) + elif provider == WebhookProvider.DISCORD.value: + payload = self._format_discord_payload(event_type, data, config) + elif provider == WebhookProvider.TEAMS.value: + payload = self._format_teams_payload(event_type, data, config) + else: + # Provider générique + payload = self._format_generic_payload(event_type, data, config) + + # Envoi avec retry + return self._send_with_retry(webhook_url, payload, config) + + except Exception as e: + logger.exception(f"Erreur lors de l'envoi au provider {provider}: {str(e)}") + return False + + def _send_with_retry( + self, url: str, payload: Dict[str, Any], config: Dict[str, Any] + ) -> bool: + """ + Envoie une requête avec mécanisme de retry. + + Args: + url: URL du webhook + payload: Données à envoyer + config: Configuration du provider + + Returns: + True si l'envoi a réussi, False sinon + """ + provider_timeout = config.get("timeout", self.timeout) + retry_count = self.retry_count + retry_delay = self.retry_delay + + # Headers par défaut + headers = {"Content-Type": "application/json"} + + # En cas d'échec, réessayer + for attempt in range(retry_count + 1): + try: + response = requests.post( + url, json=payload, headers=headers, timeout=provider_timeout + ) + + # Vérification du statut selon le provider + if 200 <= response.status_code < 300: + return True + + logger.warning( + f"Tentative {attempt + 1}/{retry_count + 1}: " + f"Échec avec code {response.status_code}: {response.text}" + ) + + # Attendre avant de réessayer, sauf pour la dernière tentative + if attempt < retry_count: + import time + + time.sleep(retry_delay) + + except Exception as e: + logger.warning( + f"Tentative {attempt + 1}/{retry_count + 1}: Exception: {str(e)}" + ) + + return False + + def _format_slack_payload( + self, event_type: str, data: Dict[str, Any], config: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Formate les données pour Slack. + + Args: + event_type: Type d'événement + data: Données à inclure + config: Configuration Slack + + Returns: + Payload formaté pour Slack + """ + # Récupérer le template + templates = config.get("templates", {}) + template = templates.get(event_type, templates.get("default", {})) + + if not template: + # Template minimal par défaut + template = { + "title": event_type.replace("_", " ").title(), + "text": f"Événement {event_type}", + "color": "#36a64f", + } + + # Formater le titre et le texte + title = self._format_string(template.get("title", ""), data) + text = self._format_string(template.get("text", ""), data) + color = template.get("color", "#36a64f") + + # Construction des attachments + attachment = { + "color": color, + "title": title, + "text": text, + "fields": [], + "footer": f"GitHub Runner Manager • {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + "mrkdwn_in": ["text", "fields"], + } + + # Ajout des champs + fields = template.get("fields", []) + for field in fields: + field_name = self._format_string(field.get("name", ""), data) + field_value = self._format_string(field.get("value", ""), data) + field_short = field.get("short", True) + + attachment["fields"].append( + {"title": field_name, "value": field_value, "short": field_short} + ) + + # Message complet + payload = { + "username": config.get("username", "GitHub Runner Manager"), + "text": text if not template.get("use_attachment", True) else "", + "attachments": [attachment] if template.get("use_attachment", True) else [], + } + + # Ajouter le channel si spécifié + channel = config.get("channel") + if channel: + payload["channel"] = channel + + return payload + + def _format_discord_payload( + self, event_type: str, data: Dict[str, Any], config: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Formate les données pour Discord. + + Args: + event_type: Type d'événement + data: Données à inclure + config: Configuration Discord + + Returns: + Payload formaté pour Discord + """ + # Récupérer le template + templates = config.get("templates", {}) + template = templates.get(event_type, templates.get("default", {})) + + if not template: + # Template minimal par défaut + template = { + "title": event_type.replace("_", " ").title(), + "description": f"Événement {event_type}", + "color": 3066993, # Vert + } + + # Formater le titre et la description + title = self._format_string(template.get("title", ""), data) + description = self._format_string(template.get("description", ""), data) + color = template.get("color", 3066993) # Couleur par défaut: vert + + # Construction de l'embed + embed = { + "title": title, + "description": description, + "color": color, + "fields": [], + "timestamp": datetime.now().isoformat(), + } + + # Ajout des champs + fields = template.get("fields", []) + for field in fields: + field_name = self._format_string(field.get("name", ""), data) + field_value = self._format_string(field.get("value", ""), data) + field_inline = field.get("inline", True) + + embed["fields"].append( + {"name": field_name, "value": field_value, "inline": field_inline} + ) + + # Message complet + payload = { + "username": config.get("username", "GitHub Runner Manager"), + "avatar_url": config.get("avatar_url", ""), + "embeds": [embed], + } + + return payload + + def _format_teams_payload( + self, event_type: str, data: Dict[str, Any], config: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Formate les données pour Microsoft Teams. + + Args: + event_type: Type d'événement + data: Données à inclure + config: Configuration Teams + + Returns: + Payload formaté pour Teams + """ + # Récupérer le template + templates = config.get("templates", {}) + template = templates.get(event_type, templates.get("default", {})) + + if not template: + # Template minimal par défaut + template = { + "title": event_type.replace("_", " ").title(), + "themeColor": "0076D7", # Bleu + "sections": [{"activityTitle": f"Événement {event_type}", "facts": []}], + } + + # Formater le titre + title = self._format_string(template.get("title", ""), data) + theme_color = template.get("themeColor", "0076D7") + + # Sections + sections = [] + template_sections = template.get("sections", []) + + for section_template in template_sections: + section = {} + + # Titre de l'activité + if "activityTitle" in section_template: + section["activityTitle"] = self._format_string( + section_template["activityTitle"], data + ) + + # Faits + if "facts" in section_template: + facts = [] + for fact_template in section_template["facts"]: + fact = { + "name": self._format_string( + fact_template.get("name", ""), data + ), + "value": self._format_string( + fact_template.get("value", ""), data + ), + } + facts.append(fact) + section["facts"] = facts + + sections.append(section) + + # Message complet (Card adaptative) + payload = { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "summary": title, + "themeColor": theme_color, + "title": title, + "sections": sections, + } + + return payload + + def _format_generic_payload( + self, event_type: str, data: Dict[str, Any], config: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Formate les données pour un webhook générique. + + Args: + event_type: Type d'événement + data: Données à inclure + config: Configuration du webhook + + Returns: + Payload formaté pour le webhook générique + """ + # Message simple pour webhooks génériques + payload = { + "event_type": event_type, + "timestamp": datetime.now().isoformat(), + "data": data, + } + + return payload + + def _format_string(self, template_str: str, data: Dict[str, Any]) -> str: + """ + Formate une chaîne en remplaçant les variables par leurs valeurs. + + Args: + template_str: Chaîne template avec variables {var} + data: Dictionnaire de données + + Returns: + Chaîne formatée + """ + try: + return template_str.format(**data) + except KeyError as e: + logger.warning(f"Variable manquante dans le template: {e}") + return template_str + except Exception as e: + logger.warning(f"Erreur lors du formatage: {e}") + return template_str diff --git a/tests/conftest.py b/tests/conftest.py index 5adecff..9bb908d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,22 @@ from src.services.config_schema import FullConfig +@pytest.fixture(autouse=True) +def block_real_webhook_requests(): + """Empêche tout envoi HTTP sortant via requests.post (webhooks) pendant les tests.""" + with patch("requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.text = "MOCKED" + yield mock_post + + +@pytest.fixture(autouse=True) +def mock_webhook_service(): + """Patch global de WebhookService pour désactiver les notifications réelles dans tous les tests.""" + with patch("src.services.notification_service.WebhookService") as mock: + yield mock + + @pytest.fixture def valid_config(): """Fixture pour une configuration valide des runners.""" diff --git a/tests/docker_service/test_workflows.py b/tests/docker_service/test_workflows.py index 212bb67..472ac26 100644 --- a/tests/docker_service/test_workflows.py +++ b/tests/docker_service/test_workflows.py @@ -73,6 +73,76 @@ def test_start_runners_creates_and_starts(docker_service, config_service): assert res["started"] +def test_start_runners_extra_running_does_not_start_before_remove( + docker_service, config_service +): + # Arrange: one extra container already RUNNING + prefix = config_service.load_config.return_value.runners[0].name_prefix + extra_name = f"{prefix}-3" + + # Return extra only for the matching group + docker_service.list_containers = MagicMock( + side_effect=lambda pattern=None: ( + [extra_name] if pattern and pattern.startswith(prefix + "-") else [] + ) + ) + docker_service.container_running = MagicMock(return_value=True) + docker_service.image_exists = MagicMock(return_value=True) + docker_service.start_container = MagicMock() + docker_service.exec_command = MagicMock() + docker_service.remove_container = MagicMock() + # Avoid creating regular runners 1..nb + docker_service.container_exists = MagicMock(return_value=False) + docker_service.run_container = MagicMock() + docker_service._get_registration_token = MagicMock(return_value="tok") + + # Keep a small nb so idx>nb is true for extra_name + config_service.load_config.return_value.runners[0].nb = 2 + + # Act + res = docker_service.start_runners() + + # Assert: it was removed, but start_container was NOT called on a running extra + assert {"name": extra_name} in res["removed"] + docker_service.start_container.assert_not_called() + docker_service.exec_command.assert_called_once() + docker_service.remove_container.assert_called_once() + + +def test_start_runners_extra_stopped_starts_before_remove( + docker_service, config_service +): + # Arrange: one extra container STOPPED + prefix = config_service.load_config.return_value.runners[0].name_prefix + extra_name = f"{prefix}-4" + + docker_service.list_containers = MagicMock( + side_effect=lambda pattern=None: ( + [extra_name] if pattern and pattern.startswith(prefix + "-") else [] + ) + ) + docker_service.container_running = MagicMock(return_value=False) + docker_service.image_exists = MagicMock(return_value=True) + docker_service.start_container = MagicMock() + docker_service.exec_command = MagicMock() + docker_service.remove_container = MagicMock() + # Avoid creating regular runners 1..nb + docker_service.container_exists = MagicMock(return_value=False) + docker_service.run_container = MagicMock() + docker_service._get_registration_token = MagicMock(return_value="tok") + + config_service.load_config.return_value.runners[0].nb = 3 + + # Act + res = docker_service.start_runners() + + # Assert: it was removed, and start_container was called because it was stopped + assert {"name": extra_name} in res["removed"] + docker_service.start_container.assert_called_once_with(extra_name) + docker_service.exec_command.assert_called_once() + docker_service.remove_container.assert_called_once() + + def test_stop_runners_branches(docker_service, config_service): docker_service.container_running = MagicMock( side_effect=[True, False, Exception("fail")] diff --git a/tests/notifications/test_factory_events.py b/tests/notifications/test_factory_events.py new file mode 100644 index 0000000..da754f7 --- /dev/null +++ b/tests/notifications/test_factory_events.py @@ -0,0 +1,196 @@ +""" +Tests for all event branches for start/stop/remove/update operations in events_from_operation. +Optimized for clarity and maintainability. +""" + +import pytest + +from src.notifications import events as ev + + +def collect(op, data): + """Helper to collect events from operation for test cases.""" + from src.notifications.factory import events_from_operation + + return list(events_from_operation(op, data)) + + +@pytest.mark.parametrize( + "op,data,checks", + [ + ( + "start", + { + "started": [ + { + "id": "1", + "name": "r1", + "labels": ["x"], + "techno": "py", + "techno_version": "3.12", + } + ], + "restarted": [ + { + "id": "2", + "name": "r2", + "labels": [], + "techno": "node", + "techno_version": "20", + } + ], + "errors": [{"id": "3", "name": "r3", "reason": "boom"}], + }, + [ + lambda events: any( + isinstance(e, ev.RunnerStarted) + and e.runner_id == "1" + and not e.restarted + for e in events + ), + lambda events: any( + isinstance(e, ev.RunnerStarted) + and e.runner_id == "2" + and e.restarted + for e in events + ), + lambda events: any( + isinstance(e, ev.RunnerError) + and e.runner_id == "3" + and e.error_message == "boom" + for e in events + ), + ], + ), + ( + "stop", + { + "stopped": [{"id": "1", "name": "r1", "uptime": "10s"}], + "errors": [{"id": "2", "name": "r2", "reason": "oops"}], + "skipped": [{"name": "r3"}], + }, + [ + lambda events: any( + isinstance(e, ev.RunnerStopped) + and e.runner_id == "1" + and e.uptime == "10s" + for e in events + ), + lambda events: any( + isinstance(e, ev.RunnerError) + and e.runner_id == "2" + and e.error_message == "oops" + for e in events + ), + lambda events: any( + isinstance(e, ev.RunnerSkipped) + and e.runner_name == "r3" + and e.operation == "stop" + for e in events + ), + ], + ), + ( + "remove", + { + "deleted": [{"id": "1", "name": "r1"}], + "errors": [{"id": "2", "name": "r2", "reason": "fail"}], + "skipped": [{"name": "r3", "reason": "not found"}], + }, + [ + lambda events: any( + isinstance(e, ev.RunnerRemoved) and e.runner_id == "1" + for e in events + ), + lambda events: any( + isinstance(e, ev.RunnerError) + and e.runner_id == "2" + and e.error_message == "fail" + for e in events + ), + lambda events: any( + isinstance(e, ev.RunnerSkipped) + and e.runner_name == "r3" + and e.reason == "not found" + for e in events + ), + ], + ), + ( + "update", + { + "update_available": True, + "current_version": "1.0", + "latest_version": "1.1", + "updated": True, + "old_version": "1.0", + "new_version": "1.1", + "new_image": "img:1.1", + "error": "err", + }, + [ + lambda events: any( + isinstance(e, ev.UpdateAvailable) and e.available_version == "1.1" + for e in events + ), + lambda events: any( + isinstance(e, ev.ImageUpdated) and e.to_version == "1.1" + for e in events + ), + lambda events: any( + isinstance(e, ev.UpdateError) and e.error_message == "err" + for e in events + ), + ], + ), + ], +) +def test_events_all_branches(op, data, checks): + """Parametrized test for all event branches.""" + events = collect(op, data) + for check in checks: + assert check(events) + + +@pytest.mark.parametrize( + "data,expected_type,expected_attr", + [ + ( + { + "update_available": True, + "current_version": "1.0", + "latest_version": "1.1", + }, + ev.UpdateAvailable, + None, + ), + ( + { + "updated": True, + "old_version": "1.0", + "new_version": "1.1", + "new_image": "img:1.1", + }, + ev.ImageUpdated, + None, + ), + ({"error": "fatal"}, ev.UpdateError, "fatal"), + ], +) +def test_update_events_single(data, expected_type, expected_attr): + """Test update events for only available, updated, or error cases.""" + events = collect("update", data) + assert len(events) == 1 + assert isinstance(events[0], expected_type) + if expected_attr: + assert getattr(events[0], "error_message", None) == expected_attr + + +def test_update_events_none(): + """Test update event returns empty for empty data.""" + assert collect("update", {}) == [] + + +def test_unknown_operation_returns_empty(): + """Test unknown operation returns empty list.""" + assert collect("unknown_operation", {"some": "data"}) == [] diff --git a/tests/presentation/cli/test_webhook_commands.py b/tests/presentation/cli/test_webhook_commands.py new file mode 100644 index 0000000..f49a311 --- /dev/null +++ b/tests/presentation/cli/test_webhook_commands.py @@ -0,0 +1,80 @@ +""" +Tests for CLI webhook commands integration and invocation logic. +Optimized for clarity and maintainability. +""" + +import pytest +from typer.testing import CliRunner + +from src.presentation.cli import commands, webhook_commands + + +@pytest.fixture +def called_dict(): + return {} + + +def test_test_webhooks_called(monkeypatch, called_dict): + """Test that test_webhooks is called with correct arguments.""" + + def fake_test_webhooks(config_service, event_type, provider, interactive, console): + called_dict.update(locals()) + + monkeypatch.setattr(webhook_commands, "test_webhooks", fake_test_webhooks) + webhook_commands.test_webhooks( + config_service="conf", + event_type="evt", + provider="prov", + interactive=True, + console=object(), + ) + assert called_dict["event_type"] == "evt" + assert called_dict["provider"] == "prov" + assert called_dict["interactive"] is True + assert called_dict["console"] is not None + + +def test_debug_test_all_templates_called(monkeypatch, called_dict): + """Test that debug_test_all_templates is called with correct arguments.""" + + def fake_debug_test_all_templates(config_service, provider, console): + called_dict.update(locals()) + + monkeypatch.setattr( + webhook_commands, "debug_test_all_templates", fake_debug_test_all_templates + ) + webhook_commands.debug_test_all_templates( + config_service="conf", provider="prov", console=object() + ) + assert called_dict["provider"] == "prov" + assert called_dict["console"] is not None + + +def test_webhook_test_cli_on_main_app(monkeypatch, called_dict): + """Test CLI 'webhook test' command invokes test_webhooks.""" + + def fake_test_webhooks(config_service, event_type, provider, interactive, console): + called_dict.update(locals()) + + monkeypatch.setattr(commands, "test_webhooks", fake_test_webhooks) + monkeypatch.setattr(commands, "console", object()) + runner = CliRunner() + result = runner.invoke( + commands.app, ["webhook", "test", "--event", "evt", "--provider", "prov"] + ) + assert result.exit_code == 0 + + +def test_webhook_test_all_cli_on_main_app(monkeypatch, called_dict): + """Test CLI 'webhook test-all' command invokes debug_test_all_templates.""" + + def fake_debug_test_all_templates(config_service, provider, console): + called_dict.update(locals()) + + monkeypatch.setattr( + commands, "debug_test_all_templates", fake_debug_test_all_templates + ) + monkeypatch.setattr(commands, "console", object()) + runner = CliRunner() + result = runner.invoke(commands.app, ["webhook", "test-all", "--provider", "prov"]) + assert result.exit_code == 0 diff --git a/tests/presentation/cli/test_webhook_commands_unit.py b/tests/presentation/cli/test_webhook_commands_unit.py new file mode 100644 index 0000000..57f9449 --- /dev/null +++ b/tests/presentation/cli/test_webhook_commands_unit.py @@ -0,0 +1,457 @@ +""" +Unit tests for src.presentation.cli.webhook_commands (prompt logic, error handling, and result display). +Optimized for clarity and maintainability. +""" + +import types + +import pytest + +from src.presentation.cli import webhook_commands + + +@pytest.fixture +def dummy_config(): + class DummyConfig: + def __init__(self, enabled=True, providers=None, events=None): + self.webhooks = types.SimpleNamespace( + enabled=enabled, + dict=lambda: {}, + model_dump=lambda: {}, + ) + self.webhooks.dict = lambda: {} + self.webhooks.model_dump = lambda: {} + self.webhooks.enabled = enabled + self.webhooks.slack = types.SimpleNamespace( + enabled=True, events=events or [], templates={} + ) + + return DummyConfig + + +@pytest.fixture +def dummy_webhook_service(): + class DummyWebhookService: + def __init__(self, providers=None): + self.providers = providers or {} + self._send_notification = lambda *a, **k: True + + def notify(self, event_type, data, provider=None): + return {provider or "slack": True} + + return DummyWebhookService + + +def test_event_type_default_non_interactive( + monkeypatch, dummy_config, dummy_webhook_service +): + """Test event_type default when not interactive.""" + + def load_config(): + return dummy_config() + + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: dummy_webhook_service(providers={"slack": {}}), + ) + console = types.SimpleNamespace(print=lambda *a, **k: None) + result = webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type=None, + interactive=False, + console=console, + ) + assert result["event_type"] == "runner_started" + + +def test_interactive_event_type_prompt( + monkeypatch, dummy_config, dummy_webhook_service +): + """Test Prompt.ask for event_type selection when interactive.""" + + def load_config(): + return dummy_config() + + called = {} + + def fake_prompt_ask(msg, choices, default): + called["asked"] = True + assert "événement" in msg + return "runner_started" + + monkeypatch.setattr( + webhook_commands, "Prompt", types.SimpleNamespace(ask=fake_prompt_ask) + ) + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: dummy_webhook_service(providers={"slack": {}}), + ) + monkeypatch.setattr( + webhook_commands, "typer", types.SimpleNamespace(confirm=lambda *a, **k: True) + ) + console = types.SimpleNamespace(print=lambda *a, **k: None) + webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type=None, + interactive=True, + console=console, + ) + assert called["asked"] + + +def test_interactive_provider_prompt(monkeypatch, dummy_config, dummy_webhook_service): + """Test Prompt.ask for provider selection when multiple providers are available.""" + + def load_config(): + return dummy_config() + + called = {"asked": False} + + def fake_prompt_ask(msg, choices, default): + called["asked"] = True + assert "provider" in msg + return "slack" + + providers = {"slack": {}, "teams": {}} + monkeypatch.setattr( + webhook_commands, "Prompt", types.SimpleNamespace(ask=fake_prompt_ask) + ) + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: dummy_webhook_service(providers=providers), + ) + monkeypatch.setattr( + webhook_commands, "typer", types.SimpleNamespace(confirm=lambda *a, **k: True) + ) + console = types.SimpleNamespace(print=lambda *a, **k: None) + webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type="runner_started", + provider=None, + interactive=True, + console=console, + ) + assert called["asked"] is True + + +def test_interactive_confirm_cancel(monkeypatch, dummy_config, dummy_webhook_service): + """Test cancel confirmation when typer.confirm returns False.""" + + def load_config(): + return dummy_config() + + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: dummy_webhook_service(providers={"slack": {}}), + ) + monkeypatch.setattr( + webhook_commands, "typer", types.SimpleNamespace(confirm=lambda *a, **k: False) + ) + monkeypatch.setattr( + webhook_commands, + "Prompt", + types.SimpleNamespace(ask=lambda *a, **k: "runner_started"), + ) + printed = {} + + def fake_print(msg, *a, **k): + if "Envoi annulé" in str(msg): + printed["cancel"] = True + + console = types.SimpleNamespace(print=fake_print) + result = webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type=None, + interactive=True, + console=console, + ) + assert result.get("cancelled") is True + assert printed.get("cancel") + + +def test_affichage_resultats(monkeypatch, dummy_config, dummy_webhook_service): + """Test result display for success and failure notifications.""" + + def load_config(): + return dummy_config() + + class DummyWS: + providers = {"slack": {}} + + def notify(self, event_type, data, provider=None): + return {"slack": True, "teams": False} + + monkeypatch.setattr(webhook_commands, "WebhookService", lambda *a, **k: DummyWS()) + monkeypatch.setattr( + webhook_commands, "typer", types.SimpleNamespace(confirm=lambda *a, **k: True) + ) + monkeypatch.setattr( + webhook_commands, + "Prompt", + types.SimpleNamespace(ask=lambda *a, **k: "runner_started"), + ) + printed = {"success": 0, "fail": 0} + + def fake_print(msg, *a, **k): + if "Notification envoyée avec succès" in str(msg): + printed["success"] += 1 + if "Échec de l'envoi" in str(msg): + printed["fail"] += 1 + + console = types.SimpleNamespace(print=fake_print) + webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type=None, + interactive=True, + console=console, + ) + assert printed["success"] == 1 + assert printed["fail"] == 1 + + +def test_debug_test_all_templates_error_config(monkeypatch): + # Couvre l'erreur "Aucune configuration webhook trouvée" (ligne 212-215) + def load_config(): + return types.SimpleNamespace() + + console = types.SimpleNamespace(print=lambda *a, **k: None) + result = webhook_commands.debug_test_all_templates( + config_service=types.SimpleNamespace(load_config=load_config), + provider=None, + console=console, + ) + assert "error" in result + + +def test_debug_test_all_templates_error_provider(monkeypatch): + # Couvre l'erreur "Aucun provider activé" (ligne 222-225) + def load_config(): + c = DummyConfig() + return c + + class DummyWS: + providers = {} + + monkeypatch.setattr(webhook_commands, "WebhookService", lambda *a, **k: DummyWS()) + console = types.SimpleNamespace(print=lambda *a, **k: None) + result = webhook_commands.debug_test_all_templates( + config_service=types.SimpleNamespace(load_config=load_config), + provider=None, + console=console, + ) + assert "error" in result + + +def test_debug_test_all_templates_affichage_echec(monkeypatch): + # Couvre l'affichage de l'échec d'envoi (ligne 273) + def load_config(): + c = DummyConfig() + return c + + class DummyWS: + providers = {"slack": {"events": ["runner_started"]}} + + def _send_notification( + self, provider_name, event_type, mock_data, provider_config + ): + return False + + monkeypatch.setattr(webhook_commands, "WebhookService", lambda *a, **k: DummyWS()) + console = types.SimpleNamespace(print=lambda *a, **k: None) + res = webhook_commands.debug_test_all_templates( + config_service=types.SimpleNamespace(load_config=load_config), + provider="slack", + console=console, + ) + assert "slack" in res + + +class DummyConfig: + def __init__(self, enabled=True, providers=None, events=None): + self.webhooks = types.SimpleNamespace( + enabled=enabled, + dict=lambda: {}, + model_dump=lambda: {}, + ) + self.webhooks.dict = lambda: {} + self.webhooks.model_dump = lambda: {} + self.webhooks.enabled = enabled + self.webhooks.slack = types.SimpleNamespace( + enabled=True, events=events or [], templates={} + ) + + +class DummyWebhookService: + def __init__(self, providers=None): + self.providers = providers or {} + self._send_notification = lambda *a, **k: True + + def notify(self, event_type, data, provider=None): + return {provider or "slack": True} + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_test_webhooks_config_missing(monkeypatch, enabled): + # config sans webhooks + def load_config(): + return types.SimpleNamespace() + + console = types.SimpleNamespace(print=lambda *a, **k: None) + result = webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type="runner_started", + interactive=False, + console=console, + ) + assert "error" in result + + # webhooks désactivés + def load_config2(): + c = DummyConfig(enabled=False) + return c + + monkeypatch.setattr( + webhook_commands, "typer", types.SimpleNamespace(confirm=lambda *a, **k: False) + ) + result = webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config2), + event_type="runner_started", + interactive=True, + console=console, + ) + assert "error" in result + + +def test_test_webhooks_provider_not_activated(monkeypatch): + # Aucun provider activé + def load_config(): + c = DummyConfig() + return c + + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: DummyWebhookService(providers={}), + ) + console = types.SimpleNamespace(print=lambda *a, **k: None) + result = webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type="runner_started", + interactive=False, + console=console, + ) + assert "error" in result + + +def test_test_webhooks_event_type_invalid(monkeypatch): + # event_type non valide + def load_config(): + c = DummyConfig() + return c + + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: DummyWebhookService(providers={"slack": {}}), + ) + console = types.SimpleNamespace(print=lambda *a, **k: None) + result = webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type="not_an_event", + interactive=False, + console=console, + ) + assert "error" in result + + +def test_test_webhooks_success(monkeypatch): + # Succès, tous les providers + def load_config(): + c = DummyConfig() + return c + + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: DummyWebhookService(providers={"slack": {}}), + ) + console = types.SimpleNamespace(print=lambda *a, **k: None) + result = webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type="runner_started", + interactive=False, + console=console, + ) + assert result["event_type"] == "runner_started" + assert result["provider"] == "all" + assert result["results"] == {"slack": True} + + +def test_test_webhooks_cancel(monkeypatch): + # Annulation par confirmation + def load_config(): + c = DummyConfig() + return c + + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: DummyWebhookService(providers={"slack": {}}), + ) + monkeypatch.setattr( + webhook_commands, "typer", types.SimpleNamespace(confirm=lambda *a, **k: False) + ) + console = types.SimpleNamespace(print=lambda *a, **k: None) + result = webhook_commands.test_webhooks( + config_service=types.SimpleNamespace(load_config=load_config), + event_type="runner_started", + interactive=True, + console=console, + ) + assert result.get("cancelled") is True + + +def test_debug_test_all_templates(monkeypatch): + # Cas provider non configuré, succès, et event non configuré + def load_config(): + c = DummyConfig() + return c + + # provider non configuré + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: DummyWebhookService( + providers={"slack": {"events": ["runner_started"]}} + ), + ) + console = types.SimpleNamespace(print=lambda *a, **k: None) + # provider non existant + webhook_commands.debug_test_all_templates( + config_service=types.SimpleNamespace(load_config=load_config), + provider="notfound", + console=console, + ) + # succès + res2 = webhook_commands.debug_test_all_templates( + config_service=types.SimpleNamespace(load_config=load_config), + provider="slack", + console=console, + ) + assert "slack" in res2 + # event non configuré + monkeypatch.setattr( + webhook_commands, + "WebhookService", + lambda *a, **k: DummyWebhookService(providers={"slack": {"events": []}}), + ) + res3 = webhook_commands.debug_test_all_templates( + config_service=types.SimpleNamespace(load_config=load_config), + provider="slack", + console=console, + ) + assert "slack" in res3 diff --git a/tests/services/test_notification_service.py b/tests/services/test_notification_service.py new file mode 100644 index 0000000..00e7e20 --- /dev/null +++ b/tests/services/test_notification_service.py @@ -0,0 +1,477 @@ +import types +from unittest.mock import patch + +import pytest + +from src.services.config_schema import ( + FullConfig, + RunnerConfig, + RunnersDefaults, + SlackConfig, + WebhooksConfig, +) + +# --- Shared fixtures to avoid Dummy* duplication in tests --- + + +@pytest.fixture +def empty_config_service(): + class DummyConfigService: + def load_config(self): + return type("C", (), {})() + + return DummyConfigService() + + +@pytest.fixture +def enabled_webhooks_config_service(): + class DummyWebhooks: + enabled = True + + def model_dump(self): + return {} + + class DummyConfigService: + def load_config(self): + return type("C", (), {"webhooks": DummyWebhooks()})() + + return DummyConfigService() + + +@pytest.fixture +def disabled_webhooks_config_service(): + class DummyWebhooks: + enabled = False + + def model_dump(self): + return {"enabled": False} + + class DummyConfigService: + def load_config(self): + return type("C", (), {"webhooks": DummyWebhooks()})() + + return DummyConfigService() + + +def test_init_webhooks_enabled_calls_build_and_register( + monkeypatch, enabled_webhooks_config_service +): + # Patch build_and_register in the module where it's used, before importing NotificationService + called = {} + + def fake_build_and_register(ws): + called["ok"] = ws + + monkeypatch.setattr( + "src.services.notification_service.build_and_register", fake_build_and_register + ) + from src.services.notification_service import NotificationService + + NotificationService(enabled_webhooks_config_service) + assert "ok" in called + + +def test_init_no_webhooks(monkeypatch, empty_config_service): + # Couvre la sortie anticipée si pas d'attribut webhooks (ligne 47) + from src.services.notification_service import NotificationService + + ns = NotificationService(empty_config_service) + assert ns.webhook_service is None + + +def test_init_webhooks_disabled_does_not_call_build_and_register( + monkeypatch, disabled_webhooks_config_service +): + # Couvre la branche "exit" de la condition if self.webhook_service.enabled (ligne 51) + called = {"called": False} + + def fake_build_and_register(ws): + called["called"] = True + + # Patch la fonction là où elle est utilisée + monkeypatch.setattr( + "src.services.notification_service.build_and_register", fake_build_and_register + ) + + # Patch WebhookService pour garantir enabled=False + class FakeWebhookService: + def __init__(self, cfg, console): + self.enabled = False + + monkeypatch.setattr( + "src.services.notification_service.WebhookService", FakeWebhookService + ) + from src.services.notification_service import NotificationService + + ns = NotificationService(disabled_webhooks_config_service) + assert ns.webhook_service is not None + assert called["called"] is False + + +def test_emit_no_webhook_service(empty_config_service): + from src.services.notification_service import NotificationService + + # Couvre la sortie anticipée de _emit si webhook_service absent (ligne 57) + ns = NotificationService(empty_config_service) + ns.webhook_service = None + ns.dispatcher = types.SimpleNamespace( + dispatch_many=lambda e: (_ for _ in ()).throw(Exception("Should not be called")) + ) + ns._emit([1, 2]) # Ne doit rien faire + + +def test_emit_webhook_service_disabled(empty_config_service): + from src.services.notification_service import NotificationService + + # Couvre la sortie anticipée de _emit si webhook_service désactivé (ligne 57) + class DummyWebhook: + enabled = False + + ns = NotificationService(empty_config_service) + ns.webhook_service = DummyWebhook() + ns.dispatcher = types.SimpleNamespace( + dispatch_many=lambda e: (_ for _ in ()).throw(Exception("Should not be called")) + ) + ns._emit([1, 2]) # Ne doit rien faire + + +def test_notify_build_completed_calls_emit( + monkeypatch, enabled_webhooks_config_service +): + from src.services.notification_service import NotificationService + + # Couvre notify_build_completed (ligne 117) + ns = NotificationService(enabled_webhooks_config_service) + called = {} + ns.webhook_service = type("W", (), {"enabled": True})() + ns.dispatcher = types.SimpleNamespace( + dispatch_many=lambda events: called.setdefault("ok", True) + ) + ns.notify_build_completed({"image_name": "img", "dockerfile": "df", "id": "id"}) + assert called["ok"] + + +def test_notify_build_failed_calls_emit(monkeypatch, enabled_webhooks_config_service): + from src.services.notification_service import NotificationService + + # Couvre notify_build_failed (ligne 130) + ns = NotificationService(enabled_webhooks_config_service) + called = {} + ns.webhook_service = type("W", (), {"enabled": True})() + ns.dispatcher = types.SimpleNamespace( + dispatch_many=lambda events: called.setdefault("ok", True) + ) + ns.notify_build_failed({"id": "id", "error_message": "fail"}) + assert called["ok"] + + +def test_notify_update_applied_calls_emit(monkeypatch, enabled_webhooks_config_service): + from src.services.notification_service import NotificationService + + # Couvre notify_update_applied (ligne 165) + ns = NotificationService(enabled_webhooks_config_service) + called = {} + ns.webhook_service = type("W", (), {"enabled": True})() + ns.dispatcher = types.SimpleNamespace( + dispatch_many=lambda events: called.setdefault("ok", True) + ) + ns.notify_update_applied( + { + "runner_type": "base", + "from_version": "1", + "to_version": "2", + "image_name": "img", + } + ) + assert called["ok"] + + +def test_notify_from_docker_result_no_webhook(empty_config_service): + from src.services.notification_service import NotificationService + + # Couvre la sortie anticipée de notify_from_docker_result (ligne 189) + ns = NotificationService(empty_config_service) + ns.webhook_service = None + ns.notify_from_docker_result("op", {}) # Ne doit rien faire + + +def test_notify_from_docker_result_webhook_disabled(empty_config_service): + from src.services.notification_service import NotificationService + + # Couvre la sortie anticipée de notify_from_docker_result (ligne 189) + class DummyWebhook: + enabled = False + + ns = NotificationService(empty_config_service) + ns.webhook_service = DummyWebhook() + ns.notify_from_docker_result("op", {}) # Ne doit rien faire + + +def test_webhook_test_calls_test_webhooks(monkeypatch): + from src.presentation.cli import commands + + called = {} + + def fake_test_webhooks(config_service, event_type, provider, interactive, console): + called.update(locals()) + + monkeypatch.setattr(commands, "test_webhooks", fake_test_webhooks) + # Patch console + monkeypatch.setattr(commands, "console", types.SimpleNamespace()) + # Appel + commands.webhook_test(event_type="evt", provider="prov") + assert called["event_type"] == "evt" + assert called["provider"] == "prov" + assert called["interactive"] is True + assert called["console"] is not None + + +def test_webhook_test_all_calls_debug_test_all_templates(monkeypatch): + from src.presentation.cli import commands + + called = {} + + def fake_debug_test_all_templates(config_service, provider, console): + called.update(locals()) + + monkeypatch.setattr( + commands, "debug_test_all_templates", fake_debug_test_all_templates + ) + # Patch console + monkeypatch.setattr(commands, "console", types.SimpleNamespace()) + # Appel + commands.webhook_test_all(provider="prov") + assert called["provider"] == "prov" + assert called["console"] is not None + + +def test_build_runners_images_runs_without_notify_build_started(monkeypatch): + """Vérifie que build_runners_images ne plante pas sans notify_build_started.""" + from src.presentation.cli import commands + + # Patch notification_service sans notify_build_started + ns = types.SimpleNamespace( + notify_from_docker_result=lambda *a, **k: None, + ) + monkeypatch.setattr(commands, "notification_service", ns) + + # Patch config_service pour retourner deux runners + class DummyRunner: + def __init__(self, id, build_image): + self.id = id + self.build_image = build_image + self.techno = "py" + self.techno_version = "3.12" + + class DummyConfig: + runners = [DummyRunner("a", True), DummyRunner("b", False)] + runners_defaults = types.SimpleNamespace(base_image="base") + + monkeypatch.setattr( + commands, + "config_service", + types.SimpleNamespace(load_config=lambda: DummyConfig()), + ) + # Patch docker_service pour ne rien faire + monkeypatch.setattr( + commands, + "docker_service", + types.SimpleNamespace( + build_runner_images=lambda quiet, use_progress: { + "built": [], + "skipped": [], + "errors": [], + } + ), + ) + # Patch console + monkeypatch.setattr( + commands, "console", types.SimpleNamespace(print=lambda *a, **k: None) + ) + # Appel + commands.build_runners_images() + # Si aucune exception, le test passe + + +def _run_remove_runners(monkeypatch, deleted, skipped): + """Helper pour patcher l'environnement et exécuter remove_runners.""" + from src.presentation.cli import commands + + notified = [] + printed = [] + + # Patch notification_service + monkeypatch.setattr( + commands, + "notification_service", + types.SimpleNamespace(notify_runner_removed=lambda d: notified.append(d)), + ) + # Patch config_service/dummy + monkeypatch.setattr( + commands, "config_service", types.SimpleNamespace(load_config=lambda: None) + ) + # Patch docker_service pour retourner deleted et skipped + monkeypatch.setattr( + commands, + "docker_service", + types.SimpleNamespace( + remove_runners=lambda: { + "deleted": deleted, + "skipped": skipped, + "errors": [], + } + ), + ) + # Patch console + monkeypatch.setattr( + commands, + "console", + types.SimpleNamespace(print=lambda *a, **k: printed.append(a[0] if a else "")), + ) + # Appel + commands.remove_runners() + return notified, printed + + +@pytest.mark.parametrize( + "deleted, skipped, expected_deleted_id, expected_deleted_name, expected_skipped_name", + [ + ( + [{"id": "x", "name": "foo"}], + [{"name": "bar"}], + "x", + "foo", + "bar", + ), + ( + [{"id": "y", "name": "baz"}], + [{"name": "qux"}], + "y", + "baz", + "qux", + ), + ], +) +def test_remove_runners_deleted_and_not_available( + monkeypatch, + deleted, + skipped, + expected_deleted_id, + expected_deleted_name, + expected_skipped_name, +): + """Vérifie la notification sur deleted et l'affichage suppression indisponible (paramétré).""" + notified, printed = _run_remove_runners(monkeypatch, deleted, skipped) + + # Vérifie notification + assert any( + d.get("runner_id") == expected_deleted_id + and d.get("runner_name") == expected_deleted_name + for d in notified + ) + # Vérifie affichage suppression indisponible + assert any( + f"{expected_skipped_name} n'est pas disponible à la suppression" in s + for s in printed + ) + + +@pytest.fixture +def mock_webhook_service(): + with patch("src.services.notification_service.WebhookService") as mock: + yield mock + + +@pytest.fixture +def notification_service(mock_config_service, mock_webhook_service): + from src.services.notification_service import NotificationService + + # Construire une config avec webhooks activés pour que NotificationService + # initialise WebhookService et appelle notify. + config = FullConfig( + runners_defaults=RunnersDefaults( + base_image="ghcr.io/actions/runner:2.300.0", + org_url="https://github.com/test-org", + ), + runners=[ + RunnerConfig( + id="t", + name_prefix="t-runner", + labels=["test"], + nb=1, + build_image=None, + techno=None, + techno_version=None, + ) + ], + webhooks=WebhooksConfig( + enabled=True, + slack=SlackConfig( + enabled=True, + webhook_url="https://example.com/webhook", + events=["runner_started"], + templates={}, + ), + ), + ) + mock_config_service.load_config.return_value = config + return NotificationService(mock_config_service) + + +def test_notify_runner_started_does_not_call_real_webhook( + notification_service, mock_webhook_service +): + runner_data = {"runner_id": "test", "runner_name": "Test Runner"} + notification_service.notify_runner_started(runner_data) + # Vérifie que le mock a bien été appelé, mais pas le vrai webhook + assert mock_webhook_service.return_value.notify.called + mock_webhook_service.return_value.notify.assert_called_with( + "runner_started", runner_data + ) + + +def test_dispatcher_supports_filters(notification_service, mock_webhook_service): + """Vérifie que le dispatcher n'invoque que les canaux dont supports(event) == True.""" + from unittest.mock import patch + + from src.notifications.channels.base import channels as real_channels + + class SupportingChannel: + name = "supporting" + + def __init__(self): + self.sent = False + + def supports(self, event): + return True + + def send(self, event): + self.sent = True + + class NonSupportingChannel: + name = "non_supporting" + + def __init__(self): + self.sent = False + + def supports(self, event): + return False + + def send(self, event): # pragma: no cover - ne devrait pas être appelé + self.sent = True + + supporting = SupportingChannel() + non_supporting = NonSupportingChannel() + + runner_data = {"runner_id": "filter-test", "runner_name": "Filter Runner"} + + # Compose une liste de canaux: custom + ceux déjà enregistrés (webhook) pour vérifier aussi l'appel d'origine + patched_list = [supporting, non_supporting] + real_channels() + + with patch("src.notifications.dispatcher.channels", return_value=patched_list): + notification_service.notify_runner_started(runner_data) + + assert supporting.sent is True + assert non_supporting.sent is False + # Le canal webhook mock doit aussi avoir été invoqué (via registration initiale) + assert mock_webhook_service.return_value.notify.called diff --git a/tests/services/test_webhook_service.py b/tests/services/test_webhook_service.py new file mode 100644 index 0000000..2f79da4 --- /dev/null +++ b/tests/services/test_webhook_service.py @@ -0,0 +1,540 @@ +""" +Tests unitaires pour WebhookService : providers, notifications, payloads, retry, formatters. +Optimisé pour clarté, maintenabilité et couverture. +""" + +import types +from typing import Any, Dict + +import pytest + +from src.services.webhook_service import WebhookService + + +@pytest.fixture +def dummy_console(): + return types.SimpleNamespace(print=lambda *a, **k: None) + + +@pytest.fixture +def service(dummy_console): + def _make(config: Dict[str, Any]): + return WebhookService(config, console=dummy_console) + + return _make + + +def test_webhook_provider_str(): + """Test string representation of WebhookProvider enum.""" + from src.services.webhook_service import WebhookProvider + + assert str(WebhookProvider.SLACK) == "slack" + assert str(WebhookProvider.DISCORD) == "discord" + assert str(WebhookProvider.TEAMS) == "teams" + assert str(WebhookProvider.GENERIC) == "generic" + + +def test_init_enabled_false_does_not_init_providers(service): + """Test that no providers are initialized if enabled is False.""" + svc = service({"enabled": False}) + assert svc.enabled is False + assert svc.providers == {} + + +def test_init_enabled_true_inits_only_enabled_providers(service): + """Test that only enabled providers are initialized.""" + svc = service( + { + "enabled": True, + "slack": {"enabled": True, "webhook_url": "http://x", "events": []}, + "discord": {"enabled": False}, + } + ) + assert svc.enabled is True + assert "slack" in svc.providers and "discord" not in svc.providers + + +def test_notify_not_enabled_returns_empty(monkeypatch, service): + """Test notify returns empty if service is not enabled.""" + svc = service({"enabled": False}) + + def boom(*a, **k): + raise AssertionError("_send_notification should not be called") + + monkeypatch.setattr(svc, "_send_notification", boom) + assert svc.notify("runner_started", {"id": 1}) == {} + + +def test_notify_unknown_provider_returns_empty(service): + """Test notify returns empty if provider is unknown.""" + svc = service({"enabled": True, "slack": {"enabled": True, "events": []}}) + assert svc.notify("runner_started", {}, provider="unknown") == {} + + +def test_notify_event_not_configured_skips_send(monkeypatch, service): + """Test notify skips sending if event is not configured.""" + svc = service( + {"enabled": True, "slack": {"enabled": True, "webhook_url": "u", "events": []}} + ) + called = {"n": 0} + + def spy(*a, **k): + called["n"] += 1 + return True + + monkeypatch.setattr(svc, "_send_notification", spy) + assert svc.notify("runner_started", {}) == {} + assert called["n"] == 0 + + +def test_notify_success_with_provider_filter(monkeypatch, service): + """Test notify returns success dict when provider is filtered and send succeeds.""" + svc = service( + { + "enabled": True, + "slack": { + "enabled": True, + "webhook_url": "http://u", + "events": ["runner_started"], + }, + } + ) + monkeypatch.setattr(svc, "_send_notification", lambda *a, **k: True) + res = svc.notify("runner_started", {"runner_id": "x"}, provider="slack") + assert res == {"slack": True} + + +def test_send_notification_missing_url_returns_false(service): + """Test _send_notification returns False if webhook_url is missing.""" + svc = service({"enabled": True}) + assert ( + svc._send_notification(provider="slack", event_type="e", data={}, config={}) + is False + ) + + +@pytest.mark.parametrize( + "provider", + ["slack", "discord", "teams", "generic"], +) +def test_send_notification_per_provider_uses_formatter(monkeypatch, provider, service): + svc = service({"enabled": True}) + # Avoid network, just confirm routing to _send_with_retry + monkeypatch.setattr(svc, "_send_with_retry", lambda *a, **k: True) + cfg = {"webhook_url": "http://u", "events": ["any"], "templates": {}} + assert ( + svc._send_notification(provider=provider, event_type="any", data={}, config=cfg) + is True + ) + + +def test_send_with_retry_success_first_try(monkeypatch, service): + + class Resp: + status_code = 200 + text = "ok" + + calls = {"n": 0} + + def fake_post(*a, **k): + calls["n"] += 1 + return Resp() + + monkeypatch.setattr("requests.post", fake_post) + svc = service({"enabled": True}) + assert svc._send_with_retry("http://u", payload={}, config={}) is True + assert calls["n"] == 1 + + +def test_send_with_retry_retry_then_success(monkeypatch, service): + + class Resp500: + status_code = 500 + text = "err" + + class Resp200: + status_code = 200 + text = "ok" + + seq = [Resp500(), Resp200()] + + def fake_post(*a, **k): + return seq.pop(0) + + monkeypatch.setattr("requests.post", fake_post) + monkeypatch.setattr("time.sleep", lambda *a, **k: None) + svc = service({"enabled": True}) + assert svc._send_with_retry("http://u", payload={}, config={}) is True + + +def test_send_with_retry_exceptions_then_success(monkeypatch, service): + + seq = [ + Exception("boom"), + Exception("boom2"), + types.SimpleNamespace(status_code=200, text="ok"), + ] + + def fake_post(*a, **k): + v = seq.pop(0) + if isinstance(v, Exception): + raise v + return v + + monkeypatch.setattr("requests.post", fake_post) + monkeypatch.setattr("time.sleep", lambda *a, **k: None) + svc = service({"enabled": True}) + assert svc._send_with_retry("http://u", payload={}, config={}) is True + + +def test_send_with_retry_all_fail_returns_false_and_sleeps(monkeypatch, service): + # Always return 500, should retry and finally return False + class Resp500: + status_code = 500 + text = "err" + + calls = {"post": 0, "sleep": 0} + + def fake_post(*a, **k): + calls["post"] += 1 + return Resp500() + + def fake_sleep(*a, **k): + calls["sleep"] += 1 + + monkeypatch.setattr("requests.post", fake_post) + monkeypatch.setattr("time.sleep", fake_sleep) + svc = service({"enabled": True}) + assert svc._send_with_retry("http://u", payload={}, config={}) is False + # Default retry_count is 3 => 4 posts, 3 sleeps + assert calls["post"] == 4 + assert calls["sleep"] == 3 + + +def test_send_with_retry_try_sleep_branch_loops(monkeypatch, service): + # Force 500 twice with retry_count=1 so we sleep once and loop exactly once + class Resp500: + status_code = 500 + text = "err" + + seq = [Resp500(), Resp500()] + calls = {"post": 0, "sleep": 0} + + def fake_post(*a, **k): + calls["post"] += 1 + return seq.pop(0) + + def fake_sleep(*a, **k): + calls["sleep"] += 1 + + monkeypatch.setattr("requests.post", fake_post) + monkeypatch.setattr("time.sleep", fake_sleep) + svc = service({"enabled": True}) + svc.retry_count = 1 + assert svc._send_with_retry("http://u", payload={}, config={}) is False + assert calls["post"] == 2 + assert calls["sleep"] == 1 + + +def test_send_with_retry_try_backedge_without_sleep_patch(monkeypatch, service): + # Ensure the try-branch 'if attempt < retry_count' back-edge is exercised + class Resp500: + status_code = 500 + text = "err" + + seq = [Resp500(), Resp500()] + + def fake_post(*a, **k): + return seq.pop(0) + + monkeypatch.setattr("requests.post", fake_post) + svc = service({"enabled": True}) + svc.retry_count = 1 + svc.retry_delay = 0 + assert svc._send_with_retry("http://u", payload={}, config={}) is False + + +def test_send_with_retry_exception_branch(monkeypatch, service): + """Test explicitly focusing on the exception branch in _send_with_retry.""" + # Track call attempts + calls = {"attempt": 0} + + # First call raises exception, second returns success + def fake_post(*a, **k): + calls["attempt"] += 1 + if calls["attempt"] == 1: + raise Exception("Simulated network error") + else: + return type("Response", (), {"status_code": 200, "text": "OK"})() + + # Skip sleep + def fake_sleep(*a, **k): + pass + + monkeypatch.setattr("requests.post", fake_post) + monkeypatch.setattr("time.sleep", fake_sleep) + + # Create service with minimal retries + svc = service({"enabled": True}) + svc.retry_count = 1 # One retry means two attempts + + # This should fail once (exception), then succeed on retry + assert svc._send_with_retry("http://u", payload={}, config={}) is True + assert ( + calls["attempt"] == 2 + ) # Verify we went through the exception path and looped back + + +def test_send_with_retry_uses_provider_timeout_override(monkeypatch, service): + captured = {"timeout": None} + + class Resp: + status_code = 200 + text = "ok" + + def fake_post(*a, **k): + captured["timeout"] = k.get("timeout") + return Resp() + + monkeypatch.setattr("requests.post", fake_post) + svc = service({"enabled": True}) + assert svc._send_with_retry("http://u", payload={}, config={"timeout": 1}) is True + assert captured["timeout"] == 1 + + +def test_format_slack_payload_use_text_no_attachment_and_fields(service): + svc = service({"enabled": True}) + cfg = { + "webhook_url": "http://u", + "templates": { + "runner_started": { + "use_attachment": False, + "title": "runner {runner_id}", + "text": "Hello {runner_id}", + "fields": [{"name": "id", "value": "{runner_id}", "short": True}], + } + }, + } + payload = svc._format_slack_payload("runner_started", {"runner_id": "X"}, cfg) + # When use_attachment is False, text is set and attachments should be empty + assert payload["text"].startswith("Hello X") + assert payload["attachments"] == [] + + +def test_format_slack_payload_with_fields(service): + svc = service({"enabled": True}) + cfg = { + "webhook_url": "http://u", + "templates": { + "runner_started": { + "use_attachment": True, + "title": "runner {runner_id}", + "text": "ignored", + "fields": [{"name": "id", "value": "{runner_id}", "short": False}], + } + }, + } + payload = svc._format_slack_payload("runner_started", {"runner_id": "X"}, cfg) + att = payload["attachments"][0] + assert any(f["title"] == "id" and f["value"] == "X" for f in att["fields"]) + + +def test_format_discord_payload_with_fields(service): + svc = service({"enabled": True}) + cfg = { + "webhook_url": "http://u", + "templates": { + "runner_started": { + "title": "Runner {runner_id}", + "description": "Desc", + "fields": [{"name": "id", "value": "{runner_id}", "inline": False}], + } + }, + } + payload = svc._format_discord_payload("runner_started", {"runner_id": "X"}, cfg) + fields = payload["embeds"][0]["fields"] + assert any( + f["name"] == "id" and f["value"] == "X" and f["inline"] is False for f in fields + ) + + +def test_format_teams_payload_with_sections_and_facts(service): + svc = service({"enabled": True}) + cfg = { + "webhook_url": "http://u", + "templates": { + "runner_started": { + "title": "Runner {runner_id}", + "sections": [ + { + "activityTitle": "Act {runner_id}", + "facts": [ + {"name": "id", "value": "{runner_id}"}, + ], + } + ], + } + }, + } + payload = svc._format_teams_payload("runner_started", {"runner_id": "X"}, cfg) + sections = payload["sections"] + assert sections and sections[0].get("facts") + assert any(f["name"] == "id" and f["value"] == "X" for f in sections[0]["facts"]) + + +def test_format_teams_payload_with_multiple_facts(service): + svc = service({"enabled": True}) + cfg = { + "webhook_url": "http://u", + "templates": { + "runner_started": { + "title": "Runner {runner_id}", + "sections": [ + { + "activityTitle": "Act {runner_id}", + "facts": [ + {"name": "id", "value": "{runner_id}"}, + {"name": "state", "value": "ok"}, + ], + } + ], + } + }, + } + payload = svc._format_teams_payload("runner_started", {"runner_id": "X"}, cfg) + facts = payload["sections"][0]["facts"] + assert any(f["name"] == "state" and f["value"] == "ok" for f in facts) + + +def test_notify_unknown_provider_prints_message(): + + messages = [] + console = types.SimpleNamespace( + print=lambda *a, **k: messages.append(a[0] if a else "") + ) + svc = WebhookService({"enabled": True, "slack": {"enabled": True}}, console=console) + assert svc.notify("runner_started", {}, provider="nope") == {} + assert any("non configuré" in str(m) for m in messages) + + +def test_format_slack_payload_defaults_and_channel(service): + + svc = service({"enabled": True}) + cfg = { + "webhook_url": "http://u", + "templates": {}, + "username": "u", + "channel": "#general", + } + payload = svc._format_slack_payload("runner_started", {"runner_id": "x"}, cfg) + assert "attachments" in payload + assert payload.get("channel") == "#general" + + +def test_format_discord_payload_defaults(service): + + svc = service({"enabled": True}) + cfg = {"webhook_url": "http://u", "templates": {}} + payload = svc._format_discord_payload("runner_started", {"runner_id": "x"}, cfg) + assert isinstance(payload.get("embeds"), list) and payload["embeds"] + + +def test_format_teams_payload_defaults(service): + + svc = service({"enabled": True}) + cfg = {"webhook_url": "http://u", "templates": {}} + payload = svc._format_teams_payload("runner_started", {"runner_id": "x"}, cfg) + assert payload.get("@type") == "MessageCard" + assert isinstance(payload.get("sections"), list) + + +def test_format_generic_payload_contains_event_and_data(service): + + svc = service({"enabled": True}) + payload = svc._format_generic_payload("runner_started", {"runner_id": "x"}, {}) + assert payload["event_type"] == "runner_started" + assert payload["data"]["runner_id"] == "x" + + +def test_format_string_missing_key_returns_template(monkeypatch, service): + + svc = service({"enabled": True}) + s = svc._format_string("Hello {name}", {"x": 1}) + # Sur KeyError, la fonction retourne la chaîne non formatée + assert s == "Hello {name}" + + +def test_format_string_invalid_template_returns_template(service): + + svc = service({"enabled": True}) + s = svc._format_string("Hello {name", {"name": "x"}) # template invalide + assert s == "Hello {name" + + +def test_notify_failure_prints_error(monkeypatch): + # Capture console messages to ensure failure branch is executed + messages = [] + console = types.SimpleNamespace( + print=lambda *a, **k: messages.append(a[0] if a else "") + ) + + svc = WebhookService( + { + "enabled": True, + "slack": { + "enabled": True, + "webhook_url": "http://u", + "events": ["evt"], + }, + }, + console=console, + ) + # Force failure of send + monkeypatch.setattr(svc, "_send_notification", lambda *a, **k: False) + res = svc.notify("evt", {"x": 1}) + assert res == {"slack": False} + assert any("Échec de l'envoi" in str(m) for m in messages) + + # Removed duplicate, unindented function definition + + +def test_send_notification_exception_returns_false(monkeypatch, service): + svc = service({"enabled": True}) + + # Make formatter raise to hit the exception branch + def _raise(*a, **k): + raise Exception("boom") + + monkeypatch.setattr(svc, "_format_generic_payload", _raise) + ok = svc._send_notification( + provider="generic", + event_type="evt", + data={}, + config={"webhook_url": "http://u"}, + ) + assert ok is False + + +def test_format_teams_payload_default_template_branch(service): + # When no template for the event, default template is used + svc = service({"enabled": True}) + cfg = {"webhook_url": "http://u", "templates": {}} + payload = svc._format_teams_payload("evt_x", {"runner_id": "X"}, cfg) + assert payload["title"].startswith("Evt X") or isinstance(payload["sections"], list) + + +def test_format_teams_payload_section_without_title_and_facts(service): + # Section without activityTitle and facts must still be appended + svc = service({"enabled": True}) + cfg = { + "webhook_url": "http://u", + "templates": { + "runner_started": { + "title": "Runner", + "sections": [{}], + } + }, + } + payload = svc._format_teams_payload("runner_started", {}, cfg) + sections = payload["sections"] + assert sections and sections[0] == {}