From e80a8d0420b18dd2b5b7aa32a439677a0985282f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20LEFER?= Date: Thu, 25 Sep 2025 09:30:46 +0200 Subject: [PATCH 1/6] fix: webhook dynamisation --- src/notifications/channels/webhook.py | 2 - src/notifications/events.py | 15 +- src/notifications/factory.py | 13 +- src/presentation/cli/commands.py | 43 +--- src/services/docker_logger.py | 4 - src/services/docker_service.py | 48 ++++- src/services/notification_service.py | 9 +- src/services/webhook_service.py | 1 + .../test_commands_build_start_stop_remove.py | 15 +- .../test_commands_check_base_image_update.py | 193 ++++++++++++++---- tests/cli/test_commands_scheduler.py | 9 - tests/docker_service/test_core.py | 6 - tests/notifications/test_factory_events.py | 36 +--- tests/services/test_notification_service.py | 24 +-- tests/test_cli_main.py | 3 - tests/test_config_service.py | 2 - 16 files changed, 229 insertions(+), 194 deletions(-) diff --git a/src/notifications/channels/webhook.py b/src/notifications/channels/webhook.py index 88f7f5f..095551e 100644 --- a/src/notifications/channels/webhook.py +++ b/src/notifications/channels/webhook.py @@ -21,10 +21,8 @@ def supports(self, event: NotificationEvent) -> bool: # pragma: no cover - triv 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) diff --git a/src/notifications/events.py b/src/notifications/events.py index 650c5b1..320634b 100644 --- a/src/notifications/events.py +++ b/src/notifications/events.py @@ -10,16 +10,13 @@ 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): @@ -34,22 +31,16 @@ def to_payload(self) -> Dict[str, Any]: # utilisé par canaux génériques 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 @@ -74,7 +65,6 @@ class RunnerSkipped(NotificationEvent): reason: str -# Build / Image Events ------------------------------------------------------- @dataclass(frozen=True) @@ -87,6 +77,8 @@ class BuildStarted(NotificationEvent): @dataclass(frozen=True) class BuildCompleted(NotificationEvent): image_name: str + duration: float + image_size: str dockerfile: str | None = None id: str | None = None @@ -94,6 +86,7 @@ class BuildCompleted(NotificationEvent): @dataclass(frozen=True) class BuildFailed(NotificationEvent): id: str | None + image_name: str error_message: str @@ -108,6 +101,7 @@ class ImageUpdated(NotificationEvent): @dataclass(frozen=True) class UpdateAvailable(NotificationEvent): runner_type: str + image_name: str current_version: str available_version: str @@ -126,7 +120,6 @@ class UpdateError(NotificationEvent): error_message: str -# Factory mapping utilitaire (option public simple) -------------------------- EVENT_NAME_TO_CLASS = { "runner_started": RunnerStarted, "runner_stopped": RunnerStopped, diff --git a/src/notifications/factory.py b/src/notifications/factory.py index bdcfd81..78dcea2 100644 --- a/src/notifications/factory.py +++ b/src/notifications/factory.py @@ -17,7 +17,6 @@ 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]): @@ -29,11 +28,14 @@ def _iter_events(operation: str, result: Dict[str, Any]): for built in result.get("built", []): yield BuildCompleted( image_name=built.get("image", ""), + duration=built.get("duration", 0.0), dockerfile=built.get("dockerfile", ""), id=built.get("id", ""), + image_size=built.get("image_size", "unknown"), ) for error in result.get("errors", []): yield BuildFailed( + image_name=error.get("image", ""), id=error.get("id", ""), error_message=error.get("reason", "Unknown error"), ) @@ -41,20 +43,13 @@ def _iter_events(operation: str, result: Dict[str, Any]): 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( @@ -66,7 +61,6 @@ def _iter_events(operation: str, result: Dict[str, Any]): 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"), ) @@ -106,6 +100,7 @@ def _iter_events(operation: str, result: Dict[str, Any]): if result.get("update_available"): yield UpdateAvailable( runner_type="base", + image_name=result.get("image_name", ""), current_version=result.get("current_version", ""), available_version=result.get("latest_version", ""), ) diff --git a/src/presentation/cli/commands.py b/src/presentation/cli/commands.py index 10428b0..dfa0715 100644 --- a/src/presentation/cli/commands.py +++ b/src/presentation/cli/commands.py @@ -5,7 +5,6 @@ import typer from rich.console import Console -# Importer les commandes webhook from src.presentation.cli.webhook_commands import ( debug_test_all_templates, test_webhooks, @@ -25,7 +24,7 @@ ) console = Console() -# Sous-commande pour les webhooks +webhook_app = typer.Typer(help="Commandes pour tester et déboguer les webhooks") webhook_app = typer.Typer(help="Commandes pour tester et déboguer les webhooks") @@ -35,12 +34,9 @@ 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) 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]" @@ -54,7 +50,6 @@ 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) @@ -67,8 +62,6 @@ def start_runners() -> 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", ""), @@ -84,15 +77,10 @@ def start_runners() -> None: 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, } ) @@ -108,8 +96,6 @@ 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", ""), @@ -128,8 +114,6 @@ def stop_runners() -> None: console.print( 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", ""), @@ -145,8 +129,6 @@ 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", ""), @@ -160,7 +142,6 @@ def stop_runners() -> None: def remove_runners() -> None: """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]") @@ -168,9 +149,6 @@ def remove_runners() -> None: {"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: name = removed.get("container") @@ -191,8 +169,6 @@ def remove_runners() -> None: 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", ""), @@ -210,8 +186,6 @@ 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", @@ -231,10 +205,10 @@ 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", + "image_name": result.get("image_name", "unknown"), "current_version": result.get("current_version", "unknown"), "available_version": result.get("latest_version", "unknown"), } @@ -250,10 +224,10 @@ def check_base_image_update() -> None: 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", + "image_name": result.get("image_name", "unknown"), "error_message": update_result.get("error", "Unknown error"), } ) @@ -262,7 +236,6 @@ def check_base_image_update() -> None: 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", @@ -272,7 +245,6 @@ def check_base_image_update() -> None: } ) - # 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')} ?" ): @@ -296,10 +268,8 @@ 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( "Voulez-vous déployer (démarrer) les nouveaux containers avec ces images ?" @@ -310,7 +280,6 @@ def check_base_image_update() -> 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", ""), @@ -326,8 +295,6 @@ def check_base_image_update() -> None: 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", ""), @@ -356,8 +323,6 @@ def check_base_image_update() -> None: 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", ""), diff --git a/src/services/docker_logger.py b/src/services/docker_logger.py index 53dd972..d92f5ea 100644 --- a/src/services/docker_logger.py +++ b/src/services/docker_logger.py @@ -18,22 +18,18 @@ def quiet_logger(line: str) -> None: - Error messages (containing "error" or "failed") - Success messages (containing "successfully" or "success") """ - # Strip whitespace from the line and return early if empty stripped_line = line.strip() if not stripped_line: return - # Keep Step lines if stripped_line.startswith("Step "): print(stripped_line) return - # Keep lines with error or failure messages if any(keyword in stripped_line.lower() for keyword in ["error", "failed"]): print(stripped_line) return - # Keep success messages success_keywords = [ "success", "successfully", diff --git a/src/services/docker_service.py b/src/services/docker_service.py index 2f00716..3b26a05 100644 --- a/src/services/docker_service.py +++ b/src/services/docker_service.py @@ -333,6 +333,7 @@ def build_runner_images( build_dir = os.path.dirname(build_image) or "." dockerfile_path = build_image + start = time.monotonic() self.build_image( image_tag=image_tag, dockerfile_path=dockerfile_path, @@ -341,18 +342,31 @@ def build_runner_images( quiet=quiet, use_progress=use_progress, ) + duration = time.monotonic() - start + client = docker.from_env() + try: + image_obj = client.images.get(image_tag) + image_size = image_obj.attrs.get("Size", 0) + except Exception: + image_size = 0 result["built"].append( { "id": getattr(runner, "name_prefix", "unknown"), "image": image_tag, + "duration": f"{duration:.2f}", "dockerfile": dockerfile_path, + "image_size": self._format_size(image_size), } ) except Exception as e: result["errors"].append( - {"id": getattr(runner, "name_prefix", "unknown"), "reason": str(e)} + { + "id": getattr(runner, "name_prefix", "unknown"), + "image": image_tag, + "reason": str(e), + } ) return result @@ -488,14 +502,22 @@ def start_runners(self) -> dict: env_vars=env_vars, ) result["started"].append( - {"name": runner_name, "reason": "image updated"} + { + "name": runner_name, + "reason": "image updated", + "labels": labels, + } ) else: if self.container_running(runner_name): - result["running"].append({"name": runner_name}) + result["running"].append( + {"name": runner_name, "labels": labels} + ) else: self.start_container(runner_name) - result["restarted"].append({"name": runner_name}) + result["restarted"].append( + {"name": runner_name, "labels": labels} + ) else: registration_token = self._get_registration_token(org_url, None) env_vars = { @@ -518,7 +540,9 @@ def start_runners(self) -> dict: command=command, env_vars=env_vars, ) - result["started"].append({"name": runner_name}) + result["started"].append( + {"name": runner_name, "labels": labels} + ) except Exception as e: result["errors"].append( {"id": runner_name, "operation": "start", "reason": str(e)} @@ -680,6 +704,7 @@ def check_base_image_update( base_image = getattr(defaults, "base_image", None) if defaults else None result: dict = { + "image_name": None, "current_version": None, "latest_version": None, "update_available": False, @@ -691,6 +716,11 @@ def check_base_image_update( result["error"] = "No base_image found in runners_defaults" return result + # Keep only the image name without version tag + result["image_name"] = ( + base_image[: base_image.rfind(":")] if ":" in base_image else base_image + ) + m = re.search(r":([\d.]+)$", base_image) result["current_version"] = m.group(1) if m else None @@ -726,3 +756,11 @@ def check_base_image_update( result["error"] = str(e) return result + + def _format_size(self, size_bytes: int) -> str: + # Returns a human-readable string for a size in bytes + for unit in ["B", "KB", "MB", "GB", "TB"]: + if size_bytes < 1024: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.2f} PB" diff --git a/src/services/notification_service.py b/src/services/notification_service.py index 095abcf..aad5a23 100644 --- a/src/services/notification_service.py +++ b/src/services/notification_service.py @@ -62,14 +62,10 @@ 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), ) ] ) @@ -78,7 +74,6 @@ 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", "") ), @@ -121,6 +116,8 @@ def notify_build_completed(self, build_data: Dict[str, Any]) -> None: ), dockerfile=build_data.get("dockerfile"), id=build_data.get("id"), + duration=build_data.get("duration", 0.0), + image_size=build_data.get("image_size", "unknown"), ) ] ) @@ -131,6 +128,7 @@ def notify_build_failed(self, build_data: Dict[str, Any]) -> None: BuildFailed( id=build_data.get("id"), error_message=build_data.get("error_message", "Unknown error"), + image_name=build_data.get("image_name", "unknown"), ) ] ) @@ -152,6 +150,7 @@ def notify_update_available(self, update_data: Dict[str, Any]) -> None: [ UpdateAvailable( runner_type=update_data.get("runner_type", "base"), + image_name=update_data.get("image_name"), current_version=update_data.get("current_version", ""), available_version=update_data.get( "available_version", update_data.get("latest_version", "") diff --git a/src/services/webhook_service.py b/src/services/webhook_service.py index cf9f43f..5386f25 100644 --- a/src/services/webhook_service.py +++ b/src/services/webhook_service.py @@ -231,6 +231,7 @@ def _format_slack_payload( Returns: Payload formaté pour Slack """ + # Récupérer le template templates = config.get("templates", {}) template = templates.get(event_type, templates.get("default", {})) diff --git a/tests/cli/test_commands_build_start_stop_remove.py b/tests/cli/test_commands_build_start_stop_remove.py index bf2858f..9da0368 100644 --- a/tests/cli/test_commands_build_start_stop_remove.py +++ b/tests/cli/test_commands_build_start_stop_remove.py @@ -1,6 +1,4 @@ -"""Consolidated tests for build/start/stop/remove commands. -Covers success, empty, skipped, and error branches with parametrization. -""" +"""Consolidated tests for build/start/stop/remove commands.""" from unittest.mock import patch @@ -208,16 +206,7 @@ def test_remove_runners( def test_check_base_image_update_build_outputs( mock_confirm, mock_check_update, mock_build, cli ): - """Covers lines printing skipped and error cases after building images - inside check_base_image_update interactive flow (lines 158 & 163). - - Flow: - 1. First confirm() returns True (apply update) - 2. Second confirm() returns True (trigger build) - 3. First call to check_base_image_update() -> update discovery - 4. Second call (auto_update=True) -> updated result - 5. build_runner_images() returns one skipped and one error to trigger prints. - """ + """Covers lines printing skipped and error cases after building images inside check_base_image_update interactive flow.""" # Two confirmations: update then build mock_confirm.side_effect = [True, True] # First call: update available diff --git a/tests/cli/test_commands_check_base_image_update.py b/tests/cli/test_commands_check_base_image_update.py index e70d0d5..8ec3d43 100644 --- a/tests/cli/test_commands_check_base_image_update.py +++ b/tests/cli/test_commands_check_base_image_update.py @@ -7,7 +7,6 @@ from src.presentation.cli.commands import app -# Pré-compile le regex ANSI une seule fois pour éviter le coût par appel ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") @@ -61,10 +60,7 @@ def test_check_base_image_update( mock_check.return_value = first_result else: mock_check.side_effect = [first_result, result2] - # If an update is available and user confirms first prompt, the CLI will ask a second confirmation - # to build images. We simulate both confirmations returning the same 'confirm' value for simplicity. if first_result.get("update_available") and confirm: - # Ajoute un True supplémentaire pour le prompt de déploiement mock_confirm.side_effect = [True, True, True] elif first_result.get("update_available") and not confirm: mock_confirm.side_effect = [False] @@ -74,22 +70,17 @@ def test_check_base_image_update( res = cli.invoke(app, ["check-base-image-update"]) assert res.exit_code == 0 - # Supprimer les codes ANSI pour faciliter la comparaison clean_stdout = strip_ansi_codes(res.stdout) - # Vérification des assertions for e in expects: assert e in clean_stdout, f"Expected '{e}' to be in '{clean_stdout}'" if first_result.get("update_available"): - # If an update was available we expect at least one confirmation (update prompt). - # If the update was applied and user agreed, a second confirmation (build prompt) may also occur. assert mock_confirm.call_count >= 1 else: mock_confirm.assert_not_called() -# New test for branch 144->exit (user declines build after update) @patch("src.services.docker_service.DockerService.build_runner_images") @patch("src.services.docker_service.DockerService.check_base_image_update") @patch("typer.confirm") @@ -97,8 +88,6 @@ def test_check_base_image_update_decline_build( mock_confirm, mock_check, mock_build, cli ): """Test branch where user declines to build images after update (branch 144->exit).""" - # First call: update available, user accepts update - # Second call: user declines build mock_check.side_effect = [ {"current_version": "1", "latest_version": "2", "update_available": True}, {"updated": True, "new_image": "img:2"}, @@ -108,16 +97,12 @@ def test_check_base_image_update_decline_build( res = cli.invoke(app, ["check-base-image-update"]) assert res.exit_code == 0 clean_stdout = strip_ansi_codes(res.stdout) - # Should mention update, but not build success or skipped lines assert "mis à jour vers" in clean_stdout assert "img:2" in clean_stdout - # Should NOT call build_runner_images mock_build.assert_not_called() - # Should not print build success message assert "buildée depuis" not in clean_stdout -# Nouveau: couverture du prompt de déploiement et des sorties started/restarted/running/removed @patch("src.services.docker_service.DockerService.start_runners") @patch("src.services.docker_service.DockerService.build_runner_images") @patch("src.services.docker_service.DockerService.check_base_image_update") @@ -125,31 +110,11 @@ def test_check_base_image_update_decline_build( @pytest.mark.parametrize( "start_result, expected_snippets, deploy_confirm", [ - ( # user refuse le déploiement -> aucune ligne de déploiement - {}, - [], - False, - ), - ( # started - {"started": [{"name": "runner-a"}]}, - ["runner-a démarré avec succès"], - True, - ), - ( # restarted - {"restarted": [{"name": "runner-b"}]}, - ["runner-b existant mais stoppé"], - True, - ), - ( # running - {"running": [{"name": "runner-c"}]}, - ["runner-c déjà démarré"], - True, - ), - ( # removed - {"removed": [{"name": "runner-d"}]}, - ["runner-d n'est plus requis"], - True, - ), + ({}, [], False), + ({"started": [{"name": "runner-a"}]}, ["runner-a démarré avec succès"], True), + ({"restarted": [{"name": "runner-b"}]}, ["runner-b existant mais stoppé"], True), + ({"running": [{"name": "runner-c"}]}, ["runner-c déjà démarré"], True), + ({"removed": [{"name": "runner-d"}]}, ["runner-d n'est plus requis"], True), ], ) def test_check_base_image_update_deploy_branches( @@ -162,10 +127,6 @@ def test_check_base_image_update_deploy_branches( expected_snippets, deploy_confirm, ): - """Couvre: - - Refus du déploiement (ligne 172 -> exit) - - Affichage des messages started / restarted / running / removed - """ # 1ère invocation: update disponible; 2ème: mise à jour appliquée mock_check.side_effect = [ {"current_version": "1", "latest_version": "2", "update_available": True}, @@ -211,3 +172,147 @@ def test_check_base_image_update_deploy_branches( assert ( snippet in clean_stdout ), f"Snippet attendu manquant: {snippet}\nSortie: {clean_stdout}" + + +@patch("src.services.webhook_service.WebhookService._send_with_retry") +@patch("src.services.docker_service.DockerService.check_base_image_update") +@patch("typer.confirm") +def test_check_base_image_update_webhook_called( + mock_confirm, mock_check, mock_webhook_send, cli +): + """Vérifie que le webhook est bien appelé avec les bonnes infos lors d'une mise à jour.""" + mock_check.side_effect = [ + { + "current_version": "1", + "latest_version": "2", + "update_available": True, + "image_name": "image:1.0.1", + }, + {"updated": True, "new_image": "img:2"}, + ] + # Accepte la mise à jour, refuse le build pour éviter des notifications de build aléatoires + mock_confirm.side_effect = [True, False] + + res = cli.invoke(app, ["check-base-image-update"]) + assert res.exit_code == 0 + + assert mock_webhook_send.called, "Le webhook n'a pas été appelé." + + titles = [] + for call in mock_webhook_send.call_args_list: + args, _ = call + payload = args[1] + if isinstance(payload, dict) and payload.get("attachments"): + att0 = payload["attachments"][0] + titles.append(att0.get("title", "")) + txt = att0.get("text", "") or payload.get("text", "") + assert "unknown" not in str(txt).lower() + assert "{" not in str(txt) and "}" not in str(txt) + for field in att0.get("fields", []): + val = str(field.get("value", "")) + assert "unknown" not in val.lower() + assert "{" not in val and "}" not in val + + assert any( + t == "Mise à jour disponible" for t in titles + ), f"Aucune notif 'Mise à jour disponible' dans: {titles}" + + +def test_webhook_channel_removes_restarted_false(monkeypatch): + """Couvre la suppression de 'restarted' si False dans WebhookChannel.send.""" + from unittest.mock import MagicMock + + from src.notifications.channels.webhook import WebhookChannel + + # Mock WebhookService + mock_svc = MagicMock() + channel = WebhookChannel(mock_svc) + + # Fake event with to_payload returning a dict with 'restarted': False + class FakeEvent: + def to_payload(self): + return { + "event_type": "runner_started", + "runner_name": "foo", + "restarted": False, + } + + event = FakeEvent() + channel.send(event) + + # Check that 'restarted' is not in the payload sent to notify + args, kwargs = mock_svc.notify.call_args + sent_payload = args[1] + assert ( + "restarted" not in sent_payload + ), f"'restarted' should be removed, got: {sent_payload}" + + +@patch("src.services.docker_service.DockerService.build_runner_images") +@patch("src.services.docker_service.DockerService.check_base_image_update") +@patch("typer.confirm") +def test_check_base_image_update_build_error_printed( + mock_confirm, mock_check, mock_build, cli +): + # Simule update disponible, update acceptée, build acceptée + mock_check.side_effect = [ + {"current_version": "1", "latest_version": "2", "update_available": True}, + {"updated": True, "new_image": "img:2"}, + ] + mock_confirm.side_effect = [True, True, True] # update, build, deploy + mock_build.return_value = { + "built": [{"id": "grp", "image": "custom:latest", "dockerfile": "Dockerfile"}], + "skipped": [], + "errors": [{"id": "grp", "reason": "fail reason"}], + } + + res = cli.invoke(app, ["check-base-image-update"]) + print(res.stdout) + assert res.exit_code == 0 + assert "grp: fail reason" in res.stdout + + +def test_build_runner_images_image_size_exception(monkeypatch): + """Couvre le except Exception: image_size = 0 dans build_runner_images.""" + from types import SimpleNamespace + + from src.services import DockerService + + class DummyConfig: + runners = [ + SimpleNamespace( + build_image="Dockerfile", + techno="x", + techno_version="1", + name_prefix="foo", + ) + ] + runners_defaults = SimpleNamespace(base_image="img:1.0.0") + + class DummyConfigService: + def load_config(self): + return DummyConfig() + + svc = DockerService(DummyConfigService()) + monkeypatch.setattr(svc, "build_image", lambda **kwargs: None) + + class DummyImages: + def get(self, tag): + raise Exception("fail") + + class DummyClient: + images = DummyImages() + + monkeypatch.setattr("docker.from_env", lambda: DummyClient()) + result = svc.build_runner_images() + assert result["built"][0]["image_size"] == "0.00 B" + + +def test_format_size_pb(): + """Couvre le cas PB dans _format_size.""" + from src.services import DockerService + + svc = DockerService(lambda: None) + # 2**60 = 1 PB + pb = 2**60 + assert svc._format_size(pb).endswith("PB") diff --git a/tests/cli/test_commands_scheduler.py b/tests/cli/test_commands_scheduler.py index df6a30c..a8c9367 100644 --- a/tests/cli/test_commands_scheduler.py +++ b/tests/cli/test_commands_scheduler.py @@ -10,26 +10,20 @@ class TestCommands: def test_scheduler_normal_execution(self): """Test l'exécution normale de la commande scheduler.""" - # Mock la méthode start pour éviter l'exécution réelle scheduler_service.start = mock.MagicMock() - # Exécuter la commande scheduler() - # Vérifier que start a été appelé scheduler_service.start.assert_called_once() def test_scheduler_keyboard_interrupt(self): """Test la commande scheduler avec KeyboardInterrupt.""" - # Mock la méthode start pour lever une KeyboardInterrupt scheduler_service.start = mock.MagicMock(side_effect=KeyboardInterrupt()) scheduler_service.stop = mock.MagicMock() console.print = mock.MagicMock() - # Exécuter la commande scheduler() - # Vérifier que les méthodes attendues ont été appelées scheduler_service.start.assert_called_once() scheduler_service.stop.assert_called_once() console.print.assert_called_once_with( @@ -38,15 +32,12 @@ def test_scheduler_keyboard_interrupt(self): def test_scheduler_exception(self): """Test la commande scheduler avec une exception générique.""" - # Mock la méthode start pour lever une exception test_exception = Exception("Test error") scheduler_service.start = mock.MagicMock(side_effect=test_exception) console.print = mock.MagicMock() - # Exécuter la commande scheduler() - # Vérifier que les méthodes attendues ont été appelées scheduler_service.start.assert_called_once() console.print.assert_called_once_with( f"[red]Erreur dans le scheduler: {str(test_exception)}[/red]" diff --git a/tests/docker_service/test_core.py b/tests/docker_service/test_core.py index 3dbf7f5..bf5c405 100644 --- a/tests/docker_service/test_core.py +++ b/tests/docker_service/test_core.py @@ -146,9 +146,3 @@ def test_list_containers_filtering(docker_service): assert set(all_names) == {"foo-1", "bar-1"} filtered = docker_service.list_containers("foo-") assert filtered == ["foo-1"] - - -@patch("src.services.docker_service.DockerService.build_image") -def test_build_runner_images_success(mock_build, docker_service, config_service): - res = docker_service.build_runner_images() - assert res["built"] and not res["errors"] diff --git a/tests/notifications/test_factory_events.py b/tests/notifications/test_factory_events.py index da754f7..b9ce56d 100644 --- a/tests/notifications/test_factory_events.py +++ b/tests/notifications/test_factory_events.py @@ -42,22 +42,10 @@ def collect(op, data): "errors": [{"id": "3", "name": "r3", "reason": "boom"}], }, [ + lambda events: any(isinstance(e, ev.RunnerStarted) for e in events), + lambda events: any(isinstance(e, ev.RunnerStarted) for e in events), 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" + isinstance(e, ev.RunnerError) and e.error_message == "boom" for e in events ), ], @@ -71,15 +59,11 @@ def collect(op, data): }, [ lambda events: any( - isinstance(e, ev.RunnerStopped) - and e.runner_id == "1" - and e.uptime == "10s" + isinstance(e, ev.RunnerStopped) 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" + isinstance(e, ev.RunnerError) and e.error_message == "oops" for e in events ), lambda events: any( @@ -98,14 +82,9 @@ def collect(op, data): "skipped": [{"name": "r3", "reason": "not found"}], }, [ + lambda events: any(isinstance(e, ev.RunnerRemoved) for e in events), 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" + isinstance(e, ev.RunnerError) and e.error_message == "fail" for e in events ), lambda events: any( @@ -119,6 +98,7 @@ def collect(op, data): ( "update", { + "image_name": "img:1.0", "update_available": True, "current_version": "1.0", "latest_version": "1.1", diff --git a/tests/services/test_notification_service.py b/tests/services/test_notification_service.py index 00e7e20..b39a7f4 100644 --- a/tests/services/test_notification_service.py +++ b/tests/services/test_notification_service.py @@ -147,7 +147,15 @@ def test_notify_build_completed_calls_emit( ns.dispatcher = types.SimpleNamespace( dispatch_many=lambda events: called.setdefault("ok", True) ) - ns.notify_build_completed({"image_name": "img", "dockerfile": "df", "id": "id"}) + ns.notify_build_completed( + { + "image_name": "img", + "dockerfile": "df", + "id": "id", + "duration": 1.0, + "image_size": "123MB", + } + ) assert called["ok"] @@ -161,7 +169,7 @@ def test_notify_build_failed_calls_emit(monkeypatch, enabled_webhooks_config_ser ns.dispatcher = types.SimpleNamespace( dispatch_many=lambda events: called.setdefault("ok", True) ) - ns.notify_build_failed({"id": "id", "error_message": "fail"}) + ns.notify_build_failed({"id": "id", "error_message": "fail", "image_name": "img"}) assert called["ok"] @@ -418,18 +426,6 @@ def notification_service(mock_config_service, mock_webhook_service): 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 diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index cdbefae..f976d79 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -26,12 +26,9 @@ def test_list(): def test_main_entrypoint(monkeypatch): - # Couvre le if __name__ == "__main__" called = {} - def fake_app(): called["app"] = True - monkeypatch.setattr(cli_main, "app", fake_app) if hasattr(cli_main, "__main__"): cli_main.__main__ diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 398fdbb..cb99589 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -24,7 +24,6 @@ def test_load_config(config_file, valid_config): config = service.load_config() assert isinstance(config, FullConfig) - # valid_config est un FullConfig (conftest), donc compare les dicts assert config.model_dump() == valid_config.model_dump() @@ -50,7 +49,6 @@ def test_save_config_with_pydantic_model(tmp_path, valid_config): assert config_path.exists() with open(config_path, "r") as f: saved_config = yaml.safe_load(f) - # Compare le dict sauvegardé avec le dict du FullConfig assert saved_config == valid_config.model_dump() From 9f6f739312533352a3d73ed23d0e1a9d57eed19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20LEFER?= Date: Fri, 26 Sep 2025 10:44:07 +0200 Subject: [PATCH 2/6] trans: translate pydocs --- src/notifications/events.py | 6 +- src/notifications/factory.py | 2 +- src/presentation/cli/commands.py | 30 +++---- src/services/config_schema.py | 18 ++-- src/services/config_service.py | 16 ++-- src/services/docker_service.py | 38 ++++---- src/services/notification_service.py | 10 +-- src/services/webhook_service.py | 106 +++++++++++------------ tests/cli/test_commands_scheduler.py | 10 +-- tests/conftest.py | 38 ++++---- tests/docker_service/test_regressions.py | 10 +-- 11 files changed, 145 insertions(+), 139 deletions(-) diff --git a/src/notifications/events.py b/src/notifications/events.py index 320634b..10125e7 100644 --- a/src/notifications/events.py +++ b/src/notifications/events.py @@ -1,7 +1,7 @@ -"""Définition des événements de notification typés. +"""Definition of typed notification events. -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. +Each event is an immutable dataclass to facilitate testing, serialization, +and extension. The ``event_type`` method provides the key used by channels. """ from __future__ import annotations diff --git a/src/notifications/factory.py b/src/notifications/factory.py index 78dcea2..c7a4c9c 100644 --- a/src/notifications/factory.py +++ b/src/notifications/factory.py @@ -1,4 +1,4 @@ -"""Factory utilitaire pour convertir les résultats d'opérations en événements typés.""" +"""Utility factory to convert operation results into typed events.""" from __future__ import annotations diff --git a/src/presentation/cli/commands.py b/src/presentation/cli/commands.py index dfa0715..c33f333 100644 --- a/src/presentation/cli/commands.py +++ b/src/presentation/cli/commands.py @@ -30,9 +30,9 @@ @app.command() def build_runners_images(quiet: bool = False, progress: bool = True) -> None: - """Build les images Docker custom des runners définis dans la config YAML. + """Build custom Docker images for runners defined in the YAML config. - --quiet : réduit la verbosité du build en affichant uniquement les étapes et erreurs. + --quiet: reduces build verbosity, showing only steps and errors. """ result = docker_service.build_runner_images(quiet=quiet, use_progress=progress) result = docker_service.build_runner_images(quiet=quiet, use_progress=progress) @@ -55,7 +55,7 @@ def build_runners_images(quiet: bool = False, progress: bool = True) -> None: @app.command() def start_runners() -> None: - """Lance les runners Docker selon la configuration YAML.""" + """Start Docker runners according to the YAML configuration.""" result = docker_service.start_runners() for started in result.get("started", []): @@ -107,7 +107,7 @@ def start_runners() -> None: @app.command() def stop_runners() -> None: - """Stoppe les runners Docker selon la configuration YAML (sans désenregistrement).""" + """Stop Docker runners according to the YAML configuration (without deregistration).""" result = docker_service.stop_runners() for stopped in result.get("stopped", []): @@ -140,7 +140,7 @@ def stop_runners() -> None: @app.command() def remove_runners() -> None: - """Désenregistre et supprime les runners Docker selon la configuration YAML.""" + """Deregister and remove Docker runners according to the YAML configuration.""" result = docker_service.remove_runners() for deleted in result.get("deleted", []): name = deleted.get("name") or deleted.get("id") or "?" @@ -180,8 +180,8 @@ def remove_runners() -> None: @app.command() def check_base_image_update() -> None: - """Vérifie si une nouvelle image runner GitHub est disponible - et propose la mise à jour du base_image dans runners_config.yaml.""" + """Check if a new GitHub runner image is available + and suggest updating base_image in runners_config.yaml.""" result = docker_service.check_base_image_update() if result.get("error"): @@ -340,7 +340,7 @@ def check_base_image_update() -> None: @app.command() def list_runners() -> None: - """Liste les runners définis dans la config et leur état""" + """List the runners defined in the config and their status.""" from rich import box from rich.table import Table @@ -415,7 +415,7 @@ def list_runners() -> None: @app.command() def scheduler() -> None: - """Démarre le scheduler pour l'exécution automatisée des tâches selon la configuration.""" + """Start the scheduler for automated task execution according to the configuration.""" try: # Utilisation du service externe pour gérer le scheduler scheduler_service.start() @@ -436,10 +436,10 @@ def webhook_test( ), ) -> None: """ - Teste l'envoi d'une notification webhook avec des données simulées. + Test sending a webhook notification with simulated data. - 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. + If no event type is specified, an interactive menu will be displayed. + If no provider is specified, all configured providers will be used. """ test_webhooks( config_service, event_type, provider, interactive=True, console=console @@ -453,10 +453,10 @@ def webhook_test_all( ) ) -> None: """ - Teste tous les templates webhook configurés. + Test all configured webhook templates. - Envoie une notification pour chaque type d'événement configuré, - pour le provider spécifié ou pour tous les providers. + Sends a notification for each configured event type, + for the specified provider or for all providers. """ debug_test_all_templates(config_service, provider, console=console) diff --git a/src/services/config_schema.py b/src/services/config_schema.py index 449aa74..4952b92 100644 --- a/src/services/config_schema.py +++ b/src/services/config_schema.py @@ -25,7 +25,7 @@ class WebhookConfig(BaseModel): # Définitions communes pour les champs de notification class NotificationField(BaseModel): - """Configuration d'un champ dans une notification""" + """Configuration for a field in a notification.""" name: str value: str @@ -35,7 +35,7 @@ class NotificationField(BaseModel): # Templates pour Slack class SlackTemplateConfig(BaseModel): - """Template pour une notification Slack""" + """Template for a Slack notification.""" title: str text: str @@ -46,7 +46,7 @@ class SlackTemplateConfig(BaseModel): # Templates pour Discord class DiscordTemplateConfig(BaseModel): - """Template pour une notification Discord""" + """Template for a Discord notification.""" title: str description: str @@ -56,7 +56,7 @@ class DiscordTemplateConfig(BaseModel): # Section pour Teams class TeamsSection(BaseModel): - """Section dans une carte Microsoft Teams""" + """Section in a Microsoft Teams card.""" activityTitle: str facts: List[Dict[str, str]] = [] @@ -64,7 +64,7 @@ class TeamsSection(BaseModel): # Templates pour Microsoft Teams class TeamsTemplateConfig(BaseModel): - """Template pour une notification Microsoft Teams""" + """Template for a Microsoft Teams notification.""" title: str themeColor: str = "0076D7" # Bleu par défaut @@ -73,7 +73,7 @@ class TeamsTemplateConfig(BaseModel): # Configuration Slack class SlackConfig(BaseModel): - """Configuration des notifications Slack""" + """Slack notification configuration.""" enabled: bool = False webhook_url: HttpUrl @@ -86,7 +86,7 @@ class SlackConfig(BaseModel): # Configuration Discord class DiscordConfig(BaseModel): - """Configuration des notifications Discord""" + """Discord notification configuration.""" enabled: bool = False webhook_url: HttpUrl @@ -99,7 +99,7 @@ class DiscordConfig(BaseModel): # Configuration Microsoft Teams class TeamsConfig(BaseModel): - """Configuration des notifications Microsoft Teams""" + """Microsoft Teams notification configuration.""" enabled: bool = False webhook_url: HttpUrl @@ -121,7 +121,7 @@ class SchedulerConfig(BaseModel): # Configuration globale des webhooks class WebhooksConfig(BaseModel): - """Configuration globale des webhooks""" + """Global webhook configuration.""" enabled: bool = False timeout: int = 10 diff --git a/src/services/config_service.py b/src/services/config_service.py index 3a38598..281338a 100644 --- a/src/services/config_service.py +++ b/src/services/config_service.py @@ -1,4 +1,4 @@ -"""Service de configuration simplifié.""" +"""Simplified configuration service.""" from pathlib import Path from typing import Any @@ -9,13 +9,15 @@ class ConfigService: - """Charge la configuration des runners depuis un fichier YAML.""" + """Load runner configuration from a YAML file.""" def __init__(self, path: str = "runners_config.yaml"): self._path = Path(path) def load_config(self) -> FullConfig: - """Charge et valide la configuration depuis le fichier YAML.""" + """ + Load and validate configuration from the YAML file. + """ if not self._path.exists(): raise FileNotFoundError(f"Configuration file not found: {self._path}") with self._path.open("r", encoding="utf-8") as f: @@ -23,12 +25,16 @@ def load_config(self) -> FullConfig: return FullConfig.model_validate(raw) def save_config(self, config: Any) -> None: - """Sauvegarde la configuration dans le fichier YAML.""" + """ + Save configuration to the YAML file. + """ if hasattr(config, "model_dump"): config = config.model_dump() with self._path.open("w", encoding="utf-8") as f: yaml.dump(config, f, default_flow_style=False) def get_config_path(self) -> str: - """Renvoie le chemin absolu du fichier de configuration.""" + """ + Return the absolute path to the configuration file. + """ return str(self._path.absolute()) diff --git a/src/services/docker_service.py b/src/services/docker_service.py index 3b26a05..033c722 100644 --- a/src/services/docker_service.py +++ b/src/services/docker_service.py @@ -1,4 +1,4 @@ -"""Service Docker pour gérer les runners GitHub Actions.""" +"""Docker service for managing GitHub Actions runners.""" import os import re @@ -27,7 +27,7 @@ class DockerService: def _get_registration_token( self, org_url: str, github_personal_token: Optional[str] = None ) -> str: - """Obtient dynamiquement un registration token via l'API GitHub.""" + """Dynamically obtain a registration token via the GitHub API.""" if github_personal_token is None: github_personal_token = os.getenv("GITHUB_TOKEN") @@ -66,7 +66,7 @@ def __init__(self, config_service: ConfigService): self.config_service = config_service def container_exists(self, name: str) -> bool: - """Vérifie si un conteneur existe (docker-py).""" + """Check if a container exists (docker-py).""" client = docker.from_env() try: @@ -78,7 +78,7 @@ def container_exists(self, name: str) -> bool: return False def container_running(self, name: str) -> bool: - """Vérifie si un conteneur est en cours d'exécution (docker-py).""" + """Check if a container is running (docker-py).""" client = docker.from_env() try: @@ -93,7 +93,7 @@ def run_command(self, cmd: List[str], info: str = "") -> None: subprocess.run(cmd, check=True) def image_exists(self, tag: str) -> bool: - """Vérifie si une image Docker existe (docker-py).""" + """Check if a Docker image exists (docker-py).""" client = docker.from_env() try: @@ -112,7 +112,7 @@ def build_image( quiet: bool = False, use_progress: bool = False, ) -> None: - """Construit une image Docker (docker-py).""" + """Build a Docker image (docker-py).""" client = docker.from_env() buildargs = build_args or {} @@ -225,28 +225,28 @@ def build_image( pass def exec_command(self, container: str, command: str) -> None: - """Exécute une commande dans un conteneur en cours d'exécution (docker-py).""" + """Execute a command in a running container (docker-py).""" client = docker.from_env() cont = client.containers.get(container) cont.exec_run(command, privileged=True, detach=False) def start_container(self, name: str) -> None: - """Démarre un conteneur (docker-py).""" + """Start a container (docker-py).""" client = docker.from_env() container = client.containers.get(name) container.start() def stop_container(self, name: str) -> None: - """Arrête un conteneur (docker-py).""" + """Stop a container (docker-py).""" client = docker.from_env() container = client.containers.get(name) container.stop() def remove_container(self, name: str, force: bool = False) -> None: - """Supprime un conteneur (docker-py).""" + """Remove a container (docker-py).""" client = docker.from_env() container = client.containers.get(name) @@ -260,7 +260,7 @@ def run_container( env_vars: Dict[str, str], detach: bool = True, ) -> None: - """Exécute un nouveau conteneur.""" + """Run a new container.""" cmd = ["docker", "run"] if detach: @@ -281,7 +281,7 @@ def run_container( self.run_command(cmd) def list_containers(self, name_pattern: Optional[str] = None) -> List[str]: - """Liste les noms des conteneurs, éventuellement filtrés par modèle (docker-py).""" + """List container names, optionally filtered by pattern (docker-py).""" client = docker.from_env() containers = client.containers.list(all=True) @@ -293,7 +293,7 @@ def list_containers(self, name_pattern: Optional[str] = None) -> List[str]: def build_runner_images( self, quiet: bool = False, use_progress: bool = False ) -> dict: - """Construit les images Docker personnalisées pour les runners.""" + """Build custom Docker images for runners.""" config = self.config_service.load_config() runners = config.runners defaults = config.runners_defaults @@ -372,7 +372,7 @@ def build_runner_images( return result def start_runners(self) -> dict: - """Démarre les runners Docker selon la configuration.""" + """Start Docker runners according to the configuration.""" config = self.config_service.load_config() defaults = config.runners_defaults @@ -551,7 +551,7 @@ def start_runners(self) -> dict: return result def stop_runners(self) -> dict: - """Arrête les runners Docker selon la configuration.""" + """Stop Docker runners according to the configuration.""" config = self.config_service.load_config() runners = getattr(config, "runners", []) @@ -581,7 +581,7 @@ def stop_runners(self) -> dict: return result def remove_runners(self) -> dict: - """Supprime les runners Docker selon la configuration.""" + """Remove Docker runners according to the configuration.""" config = self.config_service.load_config() runners = getattr(config, "runners", []) @@ -617,7 +617,7 @@ def remove_runners(self) -> dict: return result def list_runners(self) -> dict: - """Liste les runners Docker avec leur état.""" + """List Docker runners with their status.""" config = self.config_service.load_config() runners = getattr(config, "runners", []) @@ -679,7 +679,7 @@ def list_runners(self) -> dict: return result def get_latest_runner_version(self) -> Optional[str]: - """Récupère la dernière version du runner GitHub via l'API GitHub.""" + """Retrieve the latest GitHub runner version via the GitHub API.""" try: url = "https://api.github.com/repos/actions/runner/releases/latest" @@ -698,7 +698,7 @@ def get_latest_runner_version(self) -> Optional[str]: def check_base_image_update( self, config_path: str = "runners_config.yaml", auto_update: bool = False ) -> dict: - """Vérifie si une mise à jour de l'image de base du runner GitHub est disponible.""" + """Check if a base image update for the GitHub runner is available.""" config = self.config_service.load_config() defaults = getattr(config, "runners_defaults", None) base_image = getattr(defaults, "base_image", None) if defaults else None diff --git a/src/services/notification_service.py b/src/services/notification_service.py index aad5a23..1ff95d1 100644 --- a/src/services/notification_service.py +++ b/src/services/notification_service.py @@ -1,7 +1,7 @@ -"""Service de notification refactorisé avec événements et dispatcher. +"""Refactored notification service with events and dispatcher. -Compatibilité maintenue: les anciennes méthodes ``notify_*`` existent toujours -et délèguent à l'API événementielle interne. +Backward compatibility: legacy ``notify_*`` methods still exist +and delegate to the internal event-based API. """ from __future__ import annotations @@ -30,9 +30,9 @@ class NotificationService: - """Façade publique stable pour l'envoi de notifications. + """Stable public facade for sending notifications. - Internellement repose sur un dispatcher + événements typés. + Internally relies on a dispatcher and typed events. """ def __init__( diff --git a/src/services/webhook_service.py b/src/services/webhook_service.py index 5386f25..cd90f24 100644 --- a/src/services/webhook_service.py +++ b/src/services/webhook_service.py @@ -1,7 +1,7 @@ """ -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. +Webhook management service for GitHub Runner Manager. +This service provides a unified interface to send notifications to various +services such as Slack, Discord, Microsoft Teams, etc. """ import logging @@ -16,7 +16,7 @@ class WebhookProvider(Enum): - """Types de fournisseurs de webhook supportés""" + """Supported webhook provider types.""" SLACK = "slack" DISCORD = "discord" @@ -28,15 +28,15 @@ def __str__(self) -> str: class WebhookService: - """Service unifié pour la gestion des webhooks sortants.""" + """Unified service for managing outgoing webhooks.""" def __init__(self, config: Dict[str, Any], console: Optional[Console] = None): """ - Initialise le service de webhooks. + Initialize the webhook service. Args: - config: Configuration des webhooks (section 'webhooks' du fichier de config) - console: Console Rich pour l'affichage (optionnel) + config: Webhook configuration (the 'webhooks' section of the config file) + console: Rich Console for display (optional) """ self.config = config or {} self.console = console or Console() @@ -53,33 +53,33 @@ def __init__(self, config: Dict[str, Any], console: Optional[Console] = None): self._init_providers() def _init_providers(self): - """Initialise les providers de webhook configurés.""" - # Parcourir les fournisseurs connus + """Initialize configured webhook providers.""" + # Iterate over known providers for provider_name in WebhookProvider: provider_config = self.config.get(provider_name.value) - # Si le fournisseur est configuré et activé + # If the provider is configured and enabled if provider_config and provider_config.get("enabled", False): self.console.print( - f"[green]Initialisation du provider webhook [bold]{provider_name.value}[/bold][/green]" + f"[green]Initializing webhook provider [bold]{provider_name.value}[/bold][/green]" ) - # Stocker la configuration du provider + # Store the provider configuration 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. + Send a notification to all providers configured for this event. 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) + event_type: Event type to notify (runner_started, build_failed, etc.) + data: Data to include in the notification + provider: Specific provider to use (optional) Returns: - Dictionnaire avec les providers comme clés et les statuts comme valeurs + Dictionary with providers as keys and statuses as values """ if not self.enabled: logger.info("Service webhook désactivé, notification ignorée") @@ -131,16 +131,16 @@ def _send_notification( config: Dict[str, Any], ) -> bool: """ - Envoie une notification à un provider spécifique. + Send a notification to a specific provider. 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 + provider: Provider name (slack, discord, teams, etc.) + event_type: Event type to notify + data: Data to include in the notification + config: Provider configuration Returns: - True si l'envoi a réussi, False sinon + True if the send succeeded, False otherwise """ try: webhook_url = config.get("webhook_url") @@ -171,15 +171,15 @@ 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. + Send a request with retry mechanism. Args: - url: URL du webhook - payload: Données à envoyer - config: Configuration du provider + url: Webhook URL + payload: Data to send + config: Provider configuration Returns: - True si l'envoi a réussi, False sinon + True if the send succeeded, False otherwise """ provider_timeout = config.get("timeout", self.timeout) retry_count = self.retry_count @@ -221,15 +221,15 @@ 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. + Format data for Slack. Args: - event_type: Type d'événement - data: Données à inclure - config: Configuration Slack + event_type: Event type + data: Data to include + config: Slack configuration Returns: - Payload formaté pour Slack + Payload formatted for Slack """ # Récupérer le template @@ -288,15 +288,15 @@ 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. + Format data for Discord. Args: - event_type: Type d'événement - data: Données à inclure - config: Configuration Discord + event_type: Event type + data: Data to include + config: Discord configuration Returns: - Payload formaté pour Discord + Payload formatted for Discord """ # Récupérer le template templates = config.get("templates", {}) @@ -348,15 +348,15 @@ 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. + Format data for Microsoft Teams. Args: - event_type: Type d'événement - data: Données à inclure - config: Configuration Teams + event_type: Event type + data: Data to include + config: Teams configuration Returns: - Payload formaté pour Teams + Payload formatted for Teams """ # Récupérer le template templates = config.get("templates", {}) @@ -420,15 +420,15 @@ 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. + Format data for a generic webhook. Args: - event_type: Type d'événement - data: Données à inclure - config: Configuration du webhook + event_type: Event type + data: Data to include + config: Webhook configuration Returns: - Payload formaté pour le webhook générique + Payload formatted for the generic webhook """ # Message simple pour webhooks génériques payload = { @@ -441,14 +441,14 @@ def _format_generic_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. + Format a string by replacing variables with their values. Args: - template_str: Chaîne template avec variables {var} - data: Dictionnaire de données + template_str: Template string with {var} variables + data: Data dictionary Returns: - Chaîne formatée + Formatted string """ try: return template_str.format(**data) diff --git a/tests/cli/test_commands_scheduler.py b/tests/cli/test_commands_scheduler.py index a8c9367..5f73234 100644 --- a/tests/cli/test_commands_scheduler.py +++ b/tests/cli/test_commands_scheduler.py @@ -1,4 +1,4 @@ -"""Tests pour les commandes CLI.""" +"""Tests for CLI commands.""" from unittest import mock @@ -6,10 +6,10 @@ class TestCommands: - """Tests pour les commandes CLI de GitHub Runner Manager.""" + """Tests for the CLI commands of GitHub Runner Manager.""" def test_scheduler_normal_execution(self): - """Test l'exécution normale de la commande scheduler.""" + """Test normal execution of the scheduler command.""" scheduler_service.start = mock.MagicMock() scheduler() @@ -17,7 +17,7 @@ def test_scheduler_normal_execution(self): scheduler_service.start.assert_called_once() def test_scheduler_keyboard_interrupt(self): - """Test la commande scheduler avec KeyboardInterrupt.""" + """Test the scheduler command with KeyboardInterrupt.""" scheduler_service.start = mock.MagicMock(side_effect=KeyboardInterrupt()) scheduler_service.stop = mock.MagicMock() console.print = mock.MagicMock() @@ -31,7 +31,7 @@ def test_scheduler_keyboard_interrupt(self): ) def test_scheduler_exception(self): - """Test la commande scheduler avec une exception générique.""" + """Test the scheduler command with a generic exception.""" test_exception = Exception("Test error") scheduler_service.start = mock.MagicMock(side_effect=test_exception) console.print = mock.MagicMock() diff --git a/tests/conftest.py b/tests/conftest.py index 9bb908d..de1ca91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ -"""Fixtures globales et configuration pytest. +"""Global fixtures and pytest configuration. -- Fournit des services mockés partagés -- Expose un factory pour créer rapidement un ConfigService mocké avec une config donnée -- Expose un CliRunner partagé pour les tests CLI +- Provides shared mocked services +- Exposes a factory to quickly create a mocked ConfigService with a given config +- Exposes a shared CliRunner for CLI tests """ from copy import deepcopy @@ -18,7 +18,7 @@ @pytest.fixture(autouse=True) def block_real_webhook_requests(): - """Empêche tout envoi HTTP sortant via requests.post (webhooks) pendant les tests.""" + """Prevent any outgoing HTTP requests via requests.post (webhooks) during tests.""" with patch("requests.post") as mock_post: mock_post.return_value.status_code = 200 mock_post.return_value.text = "MOCKED" @@ -27,14 +27,14 @@ def block_real_webhook_requests(): @pytest.fixture(autouse=True) def mock_webhook_service(): - """Patch global de WebhookService pour désactiver les notifications réelles dans tous les tests.""" + """Global patch of WebhookService to disable real notifications in all tests.""" with patch("src.services.notification_service.WebhookService") as mock: yield mock @pytest.fixture def valid_config(): - """Fixture pour une configuration valide des runners.""" + """Fixture for a valid runners configuration.""" return FullConfig.model_validate( { "runners_defaults": { @@ -67,7 +67,7 @@ def valid_config(): @pytest.fixture def config_file(valid_config, tmp_path): - """Fixture pour un fichier de configuration temporaire.""" + """Fixture for a temporary configuration file.""" config_path = tmp_path / "test_config.yaml" # valid_config est un FullConfig, on le convertit en dict pour YAML with open(config_path, "w") as f: @@ -77,7 +77,7 @@ def config_file(valid_config, tmp_path): @pytest.fixture def mock_config_service(valid_config): - """Fixture pour un service de configuration mocké.""" + """Fixture for a mocked configuration service.""" service = create_autospec(ConfigService, spec_set=True) service.load_config.return_value = valid_config return service @@ -85,9 +85,9 @@ def mock_config_service(valid_config): @pytest.fixture def config_service_factory(valid_config): - """Factory pour créer un ConfigService mocké avec overrides. + """Factory to create a mocked ConfigService with overrides. - Exemple d'usage: + Example usage: service = config_service_factory({"runners": [...]}) """ @@ -120,7 +120,7 @@ def config_service(config_service_factory): @pytest.fixture(autouse=True) def mock_docker_client(): - """Patch global de docker.from_env pour éviter tout accès Docker réel.""" + """Global patch of docker.from_env to prevent any real Docker access.""" with patch("docker.from_env") as mock_docker: client = MagicMock() mock_docker.return_value = client @@ -129,10 +129,10 @@ def mock_docker_client(): @pytest.fixture(autouse=True) def isolate_env_and_sleep(monkeypatch): - """Isolation rapide: pas de token GitHub ni de sleep bloquant par défaut. + """Quick isolation: no GitHub token or blocking sleep by default. - - Supprime GITHUB_TOKEN pour forcer la voie courte dans _get_registration_token. - - Neutralise time.sleep dans le module docker_service pour éviter toute attente. + - Removes GITHUB_TOKEN to force the short path in _get_registration_token. + - Neutralizes time.sleep in the docker_service module to avoid any waiting. """ monkeypatch.delenv("GITHUB_TOKEN", raising=False) # Neutraliser exclusivement le sleep utilisé dans docker_service @@ -145,7 +145,7 @@ def isolate_env_and_sleep(monkeypatch): @pytest.fixture def docker_service(config_service): - """Fixture pour le service Docker avec config mockée (uses shared config_service).""" + """Fixture for the Docker service with mocked config (uses shared config_service).""" return DockerService(config_service) @@ -224,17 +224,17 @@ def _wrap_object(obj, klass): @pytest.fixture def real_config_service(config_file): - """Fixture pour un vrai service de configuration avec un fichier temporaire.""" + """Fixture for a real configuration service with a temporary file.""" return ConfigService(config_file) @pytest.fixture def real_docker_service(real_config_service): - """Fixture pour un vrai service Docker avec un fichier de configuration temporaire.""" + """Fixture for a real Docker service with a temporary configuration file.""" return DockerService(real_config_service) @pytest.fixture(scope="session") def cli(): - """CliRunner partagé pour tous les tests CLI.""" + """Shared CliRunner for all CLI tests.""" return CliRunner() diff --git a/tests/docker_service/test_regressions.py b/tests/docker_service/test_regressions.py index 2045450..04e64e9 100644 --- a/tests/docker_service/test_regressions.py +++ b/tests/docker_service/test_regressions.py @@ -61,13 +61,13 @@ def test_start_runners_removal_exception( def test_start_runners_running_and_restarted( docker_service, config_service, mock_docker_client ): - """Vérifie qu'un runner déjà démarré est classé running et qu'un autre est redémarré. + """Checks that one runner already started is classified as running and another is restarted. Conditions: - - Deux runners configurés (nb=2) - - Les containers existent déjà - - Le premier est running, le second est stoppé - - Les images correspondent (pas de redéploiement forcé par mismatch d'image) + - Two runners configured (nb=2) + - Containers already exist + - The first is running, the second is stopped + - Images match (no forced redeployment due to image mismatch) """ # Préparation config cfg = config_service.load_config.return_value From b5bf88c1e1ae34424bbb7a1c78a85783648d65d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20LEFER?= Date: Fri, 26 Sep 2025 13:08:05 +0200 Subject: [PATCH 3/6] fix: stopped runner cannot restart --- src/notifications/events.py | 5 ----- src/notifications/factory.py | 1 - src/services/docker_service.py | 15 +++++++++------ .../cli/test_commands_build_start_stop_remove.py | 3 ++- .../cli/test_commands_check_base_image_update.py | 14 +++++++++----- tests/test_cli_main.py | 2 ++ 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/notifications/events.py b/src/notifications/events.py index 10125e7..8a78ac1 100644 --- a/src/notifications/events.py +++ b/src/notifications/events.py @@ -11,7 +11,6 @@ from typing import Any, Dict, List - @dataclass(frozen=True) class NotificationEvent: timestamp: datetime = field(default_factory=lambda: datetime.now(), kw_only=True) @@ -31,8 +30,6 @@ def to_payload(self) -> Dict[str, Any]: # utilisé par canaux génériques return data - - @dataclass(frozen=True) class RunnerStarted(NotificationEvent): runner_name: str @@ -65,8 +62,6 @@ class RunnerSkipped(NotificationEvent): reason: str - - @dataclass(frozen=True) class BuildStarted(NotificationEvent): image_name: str diff --git a/src/notifications/factory.py b/src/notifications/factory.py index c7a4c9c..e930431 100644 --- a/src/notifications/factory.py +++ b/src/notifications/factory.py @@ -18,7 +18,6 @@ ) - def events_from_operation(operation: str, result: Dict[str, Any]): yield from _iter_events(operation, result) diff --git a/src/services/docker_service.py b/src/services/docker_service.py index 033c722..ccac0ee 100644 --- a/src/services/docker_service.py +++ b/src/services/docker_service.py @@ -490,10 +490,12 @@ def start_runners(self) -> dict: ), } command = ( + f"if [ ! -f .runner ]; then " f"./config.sh --url {org_url} --token {registration_token} " - f"--name {runner_name} --labels " - f"{','.join(labels) if isinstance(labels, list) else labels} " - f"--unattended && ./run.sh" + f"--name {runner_name} " + f"--labels {','.join(labels) if isinstance(labels, list) else labels} " + f"--unattended; " + f"fi && ./run.sh" ) self.run_container( name=runner_name, @@ -529,10 +531,11 @@ def start_runners(self) -> dict: ), } command = ( + f"if [ ! -f .runner ]; then " f"./config.sh --url {org_url} --token {registration_token} " - f"--name {runner_name} --labels " - f"{','.join(labels) if isinstance(labels, list) else labels} " - f"--unattended && ./run.sh" + f"--name {runner_name} --labels {','.join(labels) if isinstance(labels, list) else labels} " + f"--unattended; " + f"fi && ./run.sh" ) self.run_container( name=runner_name, diff --git a/tests/cli/test_commands_build_start_stop_remove.py b/tests/cli/test_commands_build_start_stop_remove.py index 9da0368..3eaac24 100644 --- a/tests/cli/test_commands_build_start_stop_remove.py +++ b/tests/cli/test_commands_build_start_stop_remove.py @@ -206,7 +206,8 @@ def test_remove_runners( def test_check_base_image_update_build_outputs( mock_confirm, mock_check_update, mock_build, cli ): - """Covers lines printing skipped and error cases after building images inside check_base_image_update interactive flow.""" + """Covers lines printing skipped and error cases after + building images inside check_base_image_update interactive flow.""" # Two confirmations: update then build mock_confirm.side_effect = [True, True] # First call: update available diff --git a/tests/cli/test_commands_check_base_image_update.py b/tests/cli/test_commands_check_base_image_update.py index 8ec3d43..6cb6aa1 100644 --- a/tests/cli/test_commands_check_base_image_update.py +++ b/tests/cli/test_commands_check_base_image_update.py @@ -110,11 +110,15 @@ def test_check_base_image_update_decline_build( @pytest.mark.parametrize( "start_result, expected_snippets, deploy_confirm", [ - ({}, [], False), - ({"started": [{"name": "runner-a"}]}, ["runner-a démarré avec succès"], True), - ({"restarted": [{"name": "runner-b"}]}, ["runner-b existant mais stoppé"], True), - ({"running": [{"name": "runner-c"}]}, ["runner-c déjà démarré"], True), - ({"removed": [{"name": "runner-d"}]}, ["runner-d n'est plus requis"], True), + ({}, [], False), + ({"started": [{"name": "runner-a"}]}, ["runner-a démarré avec succès"], True), + ( + {"restarted": [{"name": "runner-b"}]}, + ["runner-b existant mais stoppé"], + True, + ), + ({"running": [{"name": "runner-c"}]}, ["runner-c déjà démarré"], True), + ({"removed": [{"name": "runner-d"}]}, ["runner-d n'est plus requis"], True), ], ) def test_check_base_image_update_deploy_branches( diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index f976d79..0667e7b 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -27,8 +27,10 @@ def test_list(): def test_main_entrypoint(monkeypatch): called = {} + def fake_app(): called["app"] = True + monkeypatch.setattr(cli_main, "app", fake_app) if hasattr(cli_main, "__main__"): cli_main.__main__ From 463d5b29db08d2a1268b2f3c4468c9f53a86d4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20LEFER?= Date: Fri, 26 Sep 2025 13:12:41 +0200 Subject: [PATCH 4/6] fix: copilot returns --- src/presentation/cli/commands.py | 2 -- src/services/webhook_service.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/src/presentation/cli/commands.py b/src/presentation/cli/commands.py index c33f333..f0fa653 100644 --- a/src/presentation/cli/commands.py +++ b/src/presentation/cli/commands.py @@ -25,7 +25,6 @@ console = Console() webhook_app = typer.Typer(help="Commandes pour tester et déboguer les webhooks") -webhook_app = typer.Typer(help="Commandes pour tester et déboguer les webhooks") @app.command() @@ -35,7 +34,6 @@ def build_runners_images(quiet: bool = False, progress: bool = True) -> None: --quiet: reduces build verbosity, showing only steps and errors. """ result = docker_service.build_runner_images(quiet=quiet, use_progress=progress) - result = docker_service.build_runner_images(quiet=quiet, use_progress=progress) for built in result.get("built", []): console.print( diff --git a/src/services/webhook_service.py b/src/services/webhook_service.py index cd90f24..3fdfe84 100644 --- a/src/services/webhook_service.py +++ b/src/services/webhook_service.py @@ -232,7 +232,6 @@ def _format_slack_payload( Payload formatted for Slack """ - # Récupérer le template templates = config.get("templates", {}) template = templates.get(event_type, templates.get("default", {})) @@ -298,7 +297,6 @@ def _format_discord_payload( Returns: Payload formatted for Discord """ - # Récupérer le template templates = config.get("templates", {}) template = templates.get(event_type, templates.get("default", {})) @@ -358,7 +356,6 @@ def _format_teams_payload( Returns: Payload formatted for Teams """ - # Récupérer le template templates = config.get("templates", {}) template = templates.get(event_type, templates.get("default", {})) From 87510de4c306508400bf78981e00c531bc65260f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20LEFER?= Date: Fri, 26 Sep 2025 13:15:43 +0200 Subject: [PATCH 5/6] fix: invalid runners_config.yaml.dist --- README.md | 10 +++-- config/Dockerfile.node20 | 19 ---------- .../{Dockerfile.php83 => Dockerfile.sample} | 0 docs/assets/webhook.webp | Bin 0 -> 24636 bytes runners_config.yaml.dist | 35 ++++-------------- 5 files changed, 13 insertions(+), 51 deletions(-) delete mode 100644 config/Dockerfile.node20 rename config/{Dockerfile.php83 => Dockerfile.sample} (100%) create mode 100644 docs/assets/webhook.webp diff --git a/README.md b/README.md index 994d669..ed1a319 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ GITHUB_TOKEN=ghp_................................ 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. +![webhook](./docs/assets/webhook.webp) + Pour configurer les webhooks, ajoutez une section `webhooks` dans votre `runners_config.yaml` : ```yaml @@ -139,11 +141,11 @@ cp runners_config.yaml.dist runners_config.yaml Exemple avancé de configuration de runner (`runners_config.yaml`) : ```yaml runners: - - id: php83 - name_prefix: my-runner-php83 - labels: [my-runner-set-php83, php8.3] + - id: sample + name_prefix: my-runner-sample + labels: [my-runner-set-sample, sample] nb: 2 - build_image: ./config/Dockerfile.php83 + build_image: ./config/Dockerfile.sample techno: php techno_version: 8.3 ``` diff --git a/config/Dockerfile.node20 b/config/Dockerfile.node20 deleted file mode 100644 index 84ddbd9..0000000 --- a/config/Dockerfile.node20 +++ /dev/null @@ -1,19 +0,0 @@ -# Dockerfile pour runner Node 20 basé sur l'image de base runners_config.yaml -ARG BASE_IMAGE=ghcr.io/actions/actions-runner:latest -FROM ${BASE_IMAGE} - -USER root -RUN apt update \ - && apt install -y curl \ - && rm -rf /var/lib/apt/lists/* \ - && apt clean - -USER runner -SHELL ["/bin/bash", "-c"] -RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash \ - && . ~/.nvm/nvm.sh \ - && source ~/.bashrc \ - && nvm install v20.14.0 \ - && npm install -g yarn - -USER runner \ No newline at end of file diff --git a/config/Dockerfile.php83 b/config/Dockerfile.sample similarity index 100% rename from config/Dockerfile.php83 rename to config/Dockerfile.sample diff --git a/docs/assets/webhook.webp b/docs/assets/webhook.webp new file mode 100644 index 0000000000000000000000000000000000000000..d4869d02a39ef5e9aafe83db038a22c38dac412b GIT binary patch literal 24636 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 Date: Mon, 29 Sep 2025 08:21:33 +0200 Subject: [PATCH 6/6] trad: translate french to english --- .gitignore | 3 +- Dockerfile | 8 +- README.md | 316 ++++++------------ config/.gitkeep | 0 config/Dockerfile.sample | 2 +- config/README.md | 2 - docs/scheduler.md | 97 +++--- docs/workflow.md | 126 ++++--- pyproject.toml | 2 +- runners_config.yaml.dist | 114 +++---- src/notifications/channels/base.py | 2 +- src/notifications/channels/webhook.py | 2 +- src/notifications/dispatcher.py | 2 +- src/presentation/cli/commands.py | 99 +++--- src/presentation/cli/webhook_commands.py | 131 +++----- src/services/__init__.py | 2 +- src/services/config_schema.py | 19 +- src/services/docker_service.py | 4 +- src/services/scheduler_service.py | 111 +++--- src/services/webhook_service.py | 65 +--- .../test_commands_build_start_stop_remove.py | 33 +- .../test_commands_check_base_image_update.py | 24 +- tests/cli/test_commands_list_runners.py | 6 +- tests/cli/test_commands_scheduler.py | 4 +- tests/docker_service/test_core.py | 8 +- tests/docker_service/test_logging.py | 8 +- tests/docker_service/test_regressions.py | 4 +- .../cli/test_webhook_commands_unit.py | 8 +- tests/services/conftest.py | 7 +- tests/services/test_notification_service.py | 3 +- tests/services/test_scheduler_service.py | 143 ++++---- tests/services/test_webhook_service.py | 4 +- 32 files changed, 595 insertions(+), 764 deletions(-) delete mode 100644 config/.gitkeep delete mode 100644 config/README.md diff --git a/.gitignore b/.gitignore index 861ba0e..f166670 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ Thumbs.db # Project specific logs/ *.log -config/local.yaml +config/* +!config/Dockerfile.sample .secrets runners_config.yaml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index de40e5a..bf22e15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -# Dockerfile pour GitHub Runner Manager +# Dockerfile for GitHub Runner Manager FROM python:3.13-slim WORKDIR /app -# Installer les dépendances système nécessaires et le client Docker CLI +# Install necessary system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ git \ @@ -13,14 +13,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ supervisor \ && rm -rf /var/lib/apt/lists/* -# Copier les fichiers de l'application +# Copy application files COPY pyproject.toml poetry.lock ./ COPY src ./src COPY main.py ./ COPY README.md ./ COPY infra/docker/supervisord.conf ./ -# Installer Poetry et les dépendances Python +# Install Poetry and Python dependencies RUN pip install poetry && poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi COPY infra/docker/entrypoint.sh /entrypoint.sh diff --git a/README.md b/README.md index ed1a319..3cf2b07 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,101 @@ -# Github Runner Manager +# GitHub Runner Manager ![Banner](./docs/assets/logo.webp) -[![Workflow State](https://github.com/glefer/github-runner-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/glefer/github-runner-manager/actions/workflows/main.yml) -[![codecov](https://codecov.io/gh/glefer/github-runner-manager/branch/main/graph/badge.svg?token=JRjmc0emjT)](https://codecov.io/gh/glefer/github-runner-manager) -![Python](https://img.shields.io/badge/python-3.13-blue) +[![Workflow State](https://github.com/glefer/github-runner-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/glefer/github-runner-manager/actions/workflows/main.yml) +[![codecov](https://codecov.io/gh/glefer/github-runner-manager/branch/main/graph/badge.svg?token=JRjmc0emjT)](https://codecov.io/gh/glefer/github-runner-manager) +![Python](https://img.shields.io/badge/python-3.13-blue) [![Docker](https://img.shields.io/docker/pulls/glefer/github-runner-manager)](https://hub.docker.com/r/glefer/github-runner-manager) - -Une application Python permettant de gérer facilement vos runners github depuis n'importe quel serveur ou en local. +A Python application to easily manage your GitHub runners from any server or locally. ![Output](./docs/assets/output.webp) -## 🚀 Démarrage +--- -### Prérequis +## 🚀 Getting Started +### Requirements * Python 3.13+ * Poetry ### Installation -1. Cloner : +1. Clone: ```bash git clone https://github.com/glefer/github-runner-manager cd github-runner-manager cp runners_config.yaml.dist runners_config.yaml +cp .env.example .env +# fill the GITHUB_TOKEN with your API token (see: Configuration (.env and runners_config.yaml) section below) ``` -2. Installer : + +2. Install: ```bash poetry install ``` -3. Aide : + +3. Help: ```bash poetry run python main.py --help ``` -## 📋 Commandes +--- -``` -python main.py build-runners-images # Construire les images Docker -python main.py start-runners # Démarrer les runners Docker -python main.py stop-runners # Arrêter les runners Docker -python main.py remove-runners # Supprimer les runners Docker -python main.py check-base-image-update # Vérifier les mises à jour d'images -python main.py list-runners # Lister les runners Docker -``` +## 📋 Commands +```bash +python main.py build-runners-images # Build Docker runner images +python main.py start-runners # Start Docker runners +python main.py stop-runners # Stop Docker runners +python main.py remove-runners # Remove Docker runners +python main.py check-base-image-update # Check for base image updates +python main.py list-runners # List Docker runners +``` +--- ## ⏰ Scheduler -Le scheduler permet d'automatiser des actions sur les runners (vérification, build, etc.) selon une planification flexible définie dans le fichier de configuration (`runners_config.yaml`). +The scheduler automates runner actions (checks, builds, etc.) based on flexible planning defined in `runners_config.yaml`. -Le scheduler est lancé automatiquement dans le conteneur via Supervisor. Il n'est plus nécessaire d'activer ou désactiver le scheduler manuellement. +It is automatically started in the container via Supervisor. No manual activation/deactivation is required. -Pour plus de détails sur la configuration et le fonctionnement du scheduler, consultez la documentation dédiée : [docs/scheduler.md](./docs/scheduler.md) +For more details, see: [docs/scheduler.md](./docs/scheduler.md) --- -## Configuration (.env et runners_config.yaml) +## Configuration (.env and runners_config.yaml) -Bonne pratique : ne stockez jamais de secrets en clair dans `runners_config.yaml`. -Préférez : +As of September 2025, GitHub runner management uses a **GitHub personal token** (`admin:org`, `repo` scopes) to dynamically generate a registration token. -Exemple minimal `.env` : +Minimal `.env` example: ```dotenv GITHUB_TOKEN=ghp_................................ ``` + +**Security:** +- Never share your personal token +- Prefer tokens restricted to org/repo scope + +--- + ### 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. +GitHub Runner Manager supports sending notifications via webhooks to keep you informed of important events like runner start/stop, image builds, or available updates. ![webhook](./docs/assets/webhook.webp) -Pour configurer les webhooks, ajoutez une section `webhooks` dans votre `runners_config.yaml` : - +Example configuration (`runners_config.yaml`): ```yaml webhooks: enabled: true timeout: 10 retry_count: 3 retry_delay: 5 - - # Configuration pour Slack + + # Slack configuration slack: enabled: true webhook_url: "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" @@ -96,220 +107,107 @@ webhooks: - 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 : +**Supported events include:** +- `runner_started` +- `runner_stopped` +- `runner_removed` +- `runner_error` +- `runner_skipped` +- `build_started` +- `build_completed` +- `build_failed` +- `image_updated` +- `update_available` +- `update_applied` +- `update_error` + +**Supported providers:** - Slack - Discord - Microsoft Teams -- Webhooks génériques +- Generic webhooks -Pour des exemples de configuration complets, consultez : +**Testing webhooks:** ```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 +# Test a specific event python main.py webhook test --event runner_started --provider slack -# Tester tous les événements configurés +# Test all configured events 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 : + +--- + +## Example Runner Configuration + +A sample `runners_config.yaml.dist` file is provided. Copy it to start your own configuration: ```bash cp runners_config.yaml.dist runners_config.yaml ``` -Exemple avancé de configuration de runner (`runners_config.yaml`) : +Example runner definition: ```yaml runners: - - id: sample - name_prefix: my-runner-sample - labels: [my-runner-set-sample, sample] - nb: 2 - build_image: ./config/Dockerfile.sample - techno: php - techno_version: 8.3 + - id: sample + name_prefix: my-runner-sample + labels: [my-runner-set-sample, sample] + nb: 2 + build_image: ./config/Dockerfile.sample + techno: php + techno_version: 8.3 ``` -Pour plus de détails sur la configuration du scheduler, consultez la documentation dédiée : [docs/scheduler.md](./docs/scheduler.md) -de définitions. Chaque définition inclut au minimum `name` et `image`. - -Exemple simplifié (runners_config.yaml) : - +Simplified example: ```yaml runners: - - name: runner-1 - image: ghcr.io/actions/runner:latest - labels: [linux, docker] + - name: runner-1 + image: ghcr.io/actions/runner:latest + labels: [linux, docker] ``` -Le projet inclut un schéma de configuration (`src/services/config_schema.py`) qui -valide et normalise la configuration via Pydantic. Les tests utilisent ce schéma -pour s'assurer que les configurations d'exemple restent valides. - - -## Commandes CLI - -L'outil expose une interface en ligne de commande (Typer) documentée via l'aide : - -poetry run python main.py --help - -Commandes courantes : - -- list-runners : lister les runners définis -- start-runners : démarrer des runners -- stop-runners : arrêter des runners -- remove-runners : supprimer des runners (optionnel : en conservant les conteneurs) -- check-base-image-update : vérifier si les images de base ont des mises à jour disponibles - - - - -## Utilisation dans un container Docker +The project includes a config schema (`src/services/config_schema.py`) validated with Pydantic. -Un `Dockerfile` est fourni afin de pouvoir construire votre propre image si vous avez des besoins plus spécifiques. - -### Entrypoint du conteneur - -Le comportement du conteneur dépend du paramètre passé à l'entrée : +--- -- `server` : lance le scheduler via Supervisor (`supervisord`). -- `` : exécute la commande CLI Python (voir plus haut pour la liste des commandes). -- Aucun paramètre : affiche l'aide/usage et quitte. +## CLI Commands -### Exemple de build et run +The CLI (powered by Typer) is documented via `--help`: ```bash -# Build de l'image -docker build -t github-runner-manager . - -# Lancer le scheduler (mode "serveur") -docker run --rm -it \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v $(pwd)/runners_config.yaml:/app/runners_config.yaml \ - -v $(pwd)/config:/app/config:ro \ - github-runner-manager server - -# Lancer une commande CLI (exemple : lister les runners) -docker run --rm -it \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v $(pwd)/runners_config.yaml:/app/runners_config.yaml \ - -v $(pwd)/config:/app/config:ro \ - github-runner-manager list-runners -``` - -> ⚠️ Le montage du dossier `./config` est nécessaire pour les builds d'image runners personnalisés (Dockerfile custom). - -Vous pouvez remplacer `list-runners` par n'importe quelle commande CLI du projet. - -**Attention :** -- Le montage du socket Docker donne un accès complet à Docker sur l'hôte. À utiliser uniquement dans un contexte de confiance. -## Authentification et configuration du token - -Depuis septembre 2025, la gestion des runners GitHub utilise un token personnel GitHub (scopes : `admin:org`, `repo`) pour générer dynamiquement un registration token à chaque création ou suppression de runner. - -**Exemple de configuration dans `runners_config.yaml` :** - -```yaml -runners_defaults: - base_image: ghcr.io/actions/actions-runner:2.328.0 # Image de base commune - org_url: https://github.com/it-room - github_personal_token: # scopes: admin:org, repo +poetry run python main.py --help ``` -Le registration token n'est plus stocké en dur : il est généré à la volée via l'API GitHub et injecté dans la variable d'environnement `RUNNER_TOKEN` du container. Cette variable est utilisée pour l'enregistrement et la suppression du runner (`config.sh remove`). - -**Sécurité :** -- Ne partagez jamais votre token personnel. -- Privilégiez un token restreint à l'organisation ou au repo cible. +Common commands: +- `list-runners` – list runners +- `start-runners` – start runners +- `stop-runners` – stop runners +- `remove-runners` – remove runners +- `check-base-image-update` – check if base images have updates -# GitHub Runner Manager +--- -CLI de gestion des runners GitHub Actions avec Docker. Ce projet utilise une architecture en services simplifiée et adaptée à ses besoins. +## Usage in Docker Container +A `Dockerfile` is provided for building a custom image. -### Utilisation avec Make +### Entrypoint +- `server` → runs the scheduler via Supervisor +- `` → runs a Python CLI command +- No argument → prints help/usage +### Example build and run ```bash -make help # Afficher l'aide -make install # Installer les dépendances -make build-images # Construire les images -make start-runners # Démarrer les runners -make list-runners # Lister les runners -``` - -### Exemples avec Poetry - -```bash -poetry run python main.py build-runners-images -poetry run python main.py list-runners -``` - -## 🧪 Development +# Build +docker build -t github-runner-manager . -### Tests -```bash -poetry run pytest -``` +# Run scheduler +docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock -v $(pwd)/runners_config.yaml:/app/runners_config.yaml -v $(pwd)/config:/app/config:ro github-runner-manager server -### Qualité -```bash -poetry run black src tests -poetry run isort src tests -poetry run mypy src -poetry run pre-commit +# Run CLI (example: list runners) +docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock -v $(pwd)/runners_config.yaml:/app/runners_config.yaml -v $(pwd)/config:/app/config:ro github-runner-manager list-runners ``` +⚠️ Mounting the Docker socket gives full access to Docker on the host. Use only in trusted environments. -## Développement et tests - -### 🛠️ Stack -Python 3.13, Poetry, Typer, Rich, pytest, Black, isort, mypy, Docker, YAML. - -### Généralité -Le projet utilise `pytest` pour les tests unitaires. Les fixtures ont été -centralisées pour réduire la duplication et améliorer l'isolation des tests. - -Exécuter la suite de tests : - -poetry run pytest -q - - -## Sécurité et bonnes pratiques - -- Ne commitez jamais de secrets (`GITHUB_TOKEN`, credentials Docker) dans le - dépôt. -- Utilisez des variables d'environnement, des `.env` locaux (ignorés par Git), ou - un gestionnaire de secrets (Vault, AWS Secrets Manager, etc.). -- Attention aux images de runners publiques — préférez des images officielles ou - construites et auditées par vos équipes. - - - -## 📝 Contribution -1. Respecter la séparation des responsabilités entre services -2. Ajouter des tests pour toute nouvelle fonctionnalité -3. Documenter les APIs des services modifiés/ajoutés -4. Assurer une bonne gestion des erreurs et des cas limites - -## 📄 Licence -This project is licensed under the MIT license — see the LICENSE file. +--- diff --git a/config/.gitkeep b/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/config/Dockerfile.sample b/config/Dockerfile.sample index d95b3be..eca3360 100644 --- a/config/Dockerfile.sample +++ b/config/Dockerfile.sample @@ -1,4 +1,4 @@ -# Dockerfile pour runner PHP 8.3 basé sur l'image de base runners_config.yaml +# Dockerfile PHP 8.3 based on official GitHub Actions runner image ARG BASE_IMAGE=ghcr.io/actions/actions-runner:latest FROM ${BASE_IMAGE} diff --git a/config/README.md b/config/README.md deleted file mode 100644 index 5d27b2b..0000000 --- a/config/README.md +++ /dev/null @@ -1,2 +0,0 @@ -Ce dossier est conservé pour d'éventuelles futures configurations spécifiques à l'application. -Les Dockerfile ont été déplacés dans infra/docker/. diff --git a/docs/scheduler.md b/docs/scheduler.md index 46fa69c..9840abe 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -1,26 +1,27 @@ + # Scheduler – GitHub Runner Manager -Ce document explique la configuration et le fonctionnement du scheduler intégré à GitHub Runner Manager. +This document explains the configuration and operation of the scheduler integrated into GitHub Runner Manager. -## Fonctionnement général +## General Operation -Le scheduler permet d'automatiser des actions (vérification, build, etc.) sur les runners selon une planification flexible définie dans le fichier de configuration (`runners_config.yaml`). +The scheduler automates actions (check, build, etc.) on runners according to a flexible schedule defined in the configuration file (`runners_config.yaml`). -- **Intervalle** : Déclenchement périodique (secondes, minutes, heures) -- **Fenêtre horaire** : Plage d'heures autorisées pour l'exécution -- **Jours** : Jours de la semaine où le scheduler est actif -- **Actions** : Liste des actions à exécuter (ex : `check`, `build`, `deploy`) -- **Nombre maximal de tentatives** : Arrêt automatique après X échecs consécutifs +- **Interval**: Periodic trigger (seconds, minutes, hours) +- **Time window**: Allowed time range for execution +- **Days**: Days of the week when the scheduler is active +- **Actions**: List of actions to execute (e.g., `check`, `build`, `deploy`) +- **Maximum retries**: Automatic stop after X consecutive failures -## Exemple de configuration (`runners_config.yaml`) +## Example configuration (`runners_config.yaml`) -## Lancement et configuration du scheduler +## Starting and configuring the scheduler -Depuis la version avec Supervisor, le scheduler est automatiquement lancé dans le conteneur via supervisord. Il n'est plus nécessaire de configurer `scheduler.enabled` dans le fichier de configuration. +Since the Supervisor version, the scheduler is automatically started in the container via supervisord. It is no longer necessary to configure `scheduler.enabled` in the configuration file. -Le scheduler démarre automatiquement si le conteneur est lancé sans argument, grâce à l'entrypoint et à la configuration supervisord. +The scheduler starts automatically if the container is launched without arguments, thanks to the entrypoint and supervisord configuration. -Exemple de lancement du scheduler via Docker : +Example of starting the scheduler via Docker: ```bash docker run --rm -d \ @@ -30,53 +31,53 @@ docker run --rm -d \ a/github-runner-manager ``` -## Exemple de configuration (`runners_config.yaml`) +## Example configuration (`runners_config.yaml`) ```yaml scheduler: - check_interval: "30m" # Intervalle entre deux exécutions (ex: 30s, 10m, 1h) - time_window: "08:00-20:00" # Plage horaire autorisée (HH:MM-HH:MM) - days: [mon, tue, wed, thu, fri] # Jours autorisés (mon, tue, ...) - actions: [check, build, deploy] # Actions à exécuter (deploy = auto start des runners après build) - max_retries: 3 # Nombre maximal de tentatives en cas d'échec + check_interval: "30m" # Interval between two executions (e.g.: 30s, 10m, 1h) + time_window: "08:00-20:00" # Allowed time window (HH:MM-HH:MM) + days: [mon, tue, wed, thu, fri] # Allowed days (mon, tue, ...) + actions: [check, build, deploy] # Actions to execute (deploy = auto start runners after build) + max_retries: 3 # Maximum number of retries in case of failure ``` -## Détail des paramètres +## Parameter details -- **check_interval** : Format `` (ex: `30s`, `10m`, `1h`). Unité : - - `s` : secondes - - `m` : minutes - - `h` : heures -- **time_window** : Plage horaire d'exécution autorisée (ex: `08:00-20:00`) -- **days** : Liste des jours autorisés (`mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`) -- **actions** : - - `check` : Vérifie si une nouvelle version de l'image de base est disponible - - `build` : Reconstruit les images runners si une mise à jour est détectée (et met à jour la config si update) - - `deploy` : Si présent avec `build`, et au moins une image a été reconstruite, lance automatiquement les runners (start/restart) sans interaction. -- **max_retries** : Nombre maximal de tentatives consécutives avant arrêt automatique +- **check_interval**: Format `` (e.g.: `30s`, `10m`, `1h`). Units: + - `s`: seconds + - `m`: minutes + - `h`: hours +- **time_window**: Allowed execution time window (e.g.: `08:00-20:00`) +- **days**: List of allowed days (`mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`) +- **actions**: + - `check`: Checks if a new version of the base image is available + - `build`: Rebuilds runner images if an update is detected (and updates config if needed) + - `deploy`: If present with `build`, and at least one image has been rebuilt, automatically starts/restarts runners without interaction. +- **max_retries**: Maximum number of consecutive retries before automatic stop -## Fonctionnement détaillé +## Detailed operation -1. **Chargement de la configuration** : - - Les paramètres sont validés (syntaxe, valeurs autorisées). -2. **Planification** : - - Le scheduler utilise la librairie Python [`schedule`](https://schedule.readthedocs.io/) pour planifier les tâches. - - L'intervalle (`check_interval`) et les jours (`days`) sont combinés pour définir la fréquence d'exécution. - - La plage horaire (`time_window`) limite l'exécution aux heures autorisées. -3. **Exécution** : - - À chaque déclenchement, le scheduler vérifie la fenêtre horaire et exécute les actions configurées. - - En cas d'échec, le compteur de tentatives est incrémenté. Si le maximum est atteint, le scheduler s'arrête. +1. **Loading configuration**: + - Parameters are validated (syntax, allowed values). +2. **Scheduling**: + - The scheduler uses the Python library [`schedule`](https://schedule.readthedocs.io/) to schedule tasks. + - The interval (`check_interval`) and days (`days`) are combined to define the execution frequency. + - The time window (`time_window`) limits execution to allowed hours. +3. **Execution**: + - At each trigger, the scheduler checks the time window and executes the configured actions. + - In case of failure, the retry counter is incremented. If the maximum is reached, the scheduler stops. -## Bonnes pratiques +## Best practices -- Utilisez des intervalles raisonnables pour éviter une charge excessive. -- Privilégiez des plages horaires adaptées à vos besoins (ex : heures ouvrées). -- Surveillez les logs pour détecter d'éventuels échecs répétés. +- Use reasonable intervals to avoid excessive load. +- Prefer time windows adapted to your needs (e.g.: business hours). +- Monitor logs to detect repeated failures. -## Dépendances +## Dependencies -- [schedule](https://pypi.org/project/schedule/) : Librairie de planification Python +- [schedule](https://pypi.org/project/schedule/): Python scheduling library --- -Pour toute question ou suggestion, ouvrez une issue sur le dépôt GitHub du projet. +For any questions or suggestions, open an issue on the project's GitHub repository. diff --git a/docs/workflow.md b/docs/workflow.md index 0393f1e..ce21fa3 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -1,33 +1,32 @@ -# Documentation du fonctionnement de l'application GitHub Runner Manager +# GitHub Runner Manager Application Workflow Documentation -## Présentation générale +## General Overview -L'application permet de gérer dynamiquement des runners GitHub auto-hébergés via une interface en ligne de commande (CLI). Elle facilite le déploiement, la gestion, la configuration et la supervision de runners dans des environnements Docker, avec prise en charge de différents langages et versions (Node, PHP, etc.). +The application allows dynamic management of self-hosted GitHub runners via a command-line interface (CLI). It facilitates deployment, management, configuration, and monitoring of runners in Docker environments, with support for different languages and versions (Node, PHP, etc.). -## Fonctionnalités principales +## Main Features -- **Déploiement de runners** : Création et démarrage de nouveaux runners selon la configuration YAML. -- **Arrêt et suppression** : Arrêt, suppression ou nettoyage des runners existants. -- **Mise à jour des images** : Vérification et mise à jour des images Docker de base utilisées par les runners. -- **Liste et statut** : Affichage de la liste des runners, de leur état et de leurs informations détaillées. -- **Support multi-langages** : Gestion de runners pour différents environnements (Node, PHP, etc.) via des Dockerfiles dédiés. -- **Configuration centralisée** : Utilisation d'un fichier `runners_config.yaml` pour décrire les runners à gérer. +- **Runner Deployment**: Create and start new runners according to the YAML configuration. +- **Stop and Remove**: Stop, remove, or clean up existing runners. +- **Image Update**: Check and update the base Docker images used by runners. +- **List and Status**: Display the list of runners, their state, and detailed information. +- **Multi-language Support**: Manage runners for different environments (Node, PHP, etc.) via dedicated Dockerfiles. +- **Centralized Configuration**: Use a `runners_config.yaml` file to describe the runners to manage. -## États possibles d'un runner -$ $ -Un runner peut se trouver dans l'un des états suivants : +## Possible Runner States -- **créé** : Le runner est configuré mais pas encore démarré. -- **démarré** : Le runner est actif et prêt à exécuter des jobs GitHub Actions. -- **en cours d'exécution** : Le runner exécute actuellement un job. -- **arrêté** : Le runner est stoppé (container Docker arrêté). -- **supprimé** : Le runner et son container ont été supprimés. -- **en erreur** : Une erreur est survenue lors d'une opération (démarrage, arrêt, suppression, etc.). +A runner can be in one of the following states: -## Workflows typiques +- **created**: The runner is configured but not yet started. +- **started**: The runner is active and ready to execute GitHub Actions jobs. +- **running**: The runner is currently executing a job. +- **stopped**: The runner is stopped (Docker container stopped). +- **removed**: The runner and its container have been deleted. +- **error**: An error occurred during an operation (start, stop, remove, etc.). +## Typical Workflows -### 1. Déploiement d'un nouveau runner +### 1. Deploying a New Runner ```ascii +---------------------+ @@ -36,96 +35,95 @@ Un runner peut se trouver dans l'un des états suivants : | v +---------------------+ - | Commande CLI | + | CLI Command | | (deploy/start) | +----------+----------+ | v +---------------------+ - | Runner "créé" | + | Runner "created" | +----------+----------+ | v +---------------------+ - | Runner "démarré" | + | Runner "started" | +---------------------+ ``` -### 2. Arrêt d'un runner +### 2. Stopping a Runner ```ascii +---------------------+ - | Runner "démarré" | + | Runner "started" | +----------+----------+ | v +---------------------+ - | Commande CLI stop | + | CLI Command stop | +----------+----------+ | v +---------------------+ - | Runner "arrêté" | + | Runner "stopped" | +---------------------+ ``` -### 3. Suppression d'un runner +### 3. Removing a Runner ```ascii +---------------------+ - | Runner "arrêté" | + | Runner "stopped" | +----------+----------+ | v +---------------------+ - | Commande CLI remove | + | CLI Command remove | +----------+----------+ | v +---------------------+ - | Runner "supprimé" | + | Runner "removed" | +---------------------+ ``` -### 4. Mise à jour d'une image de base +### 4. Updating a Base Image ```ascii - +-----------------------------+ - | Commande CLI check/update | - +-------------+---------------+ - | - v - +-----------------------------+ - | Nouvelle image disponible ? | - +-------------+---------------+ - Oui / Non - / \ - v v - [Mise à jour] [Aucune action] - | - v - +-----------------------------+ - | Redéploiement runners liés | - +-----------------------------+ + +-----------------------------+ + | CLI Command check/update | + +-------------+---------------+ + | + v + +-----------------------------+ + | New image available? | + +-------------+---------------+ + Yes / No + / \ + v v + [Update] [No action] + | + v + +-----------------------------+ + | Redeploy related runners | + +-----------------------------+ ``` -## Exemples de commandes CLI +## CLI Command Examples -- `python main.py list` : Liste tous les runners et leur état. -- `python main.py start ` : Démarre un runner spécifique. -- `python main.py stop ` : Arrête un runner spécifique. -- `python main.py remove ` : Supprime un runner spécifique. -- `python main.py check-base-image-update` : Vérifie les mises à jour des images Docker de base. +- `python main.py list`: Lists all runners and their state. +- `python main.py start `: Starts a specific runner. +- `python main.py stop `: Stops a specific runner. +- `python main.py remove `: Removes a specific runner. +- `python main.py check-base-image-update`: Checks for updates to base Docker images. -## Architecture technique - -- **Fichier de configuration** : `runners_config.yaml` décrit les runners à gérer. -- **Services** : - - `config_service.py` : Gestion de la configuration. - - `docker_service.py` : Gestion des containers Docker. -- **CLI** : Interface utilisateur pour piloter l'application. +## Technical Architecture +- **Configuration File**: `runners_config.yaml` describes the runners to manage. +- **Services**: + - `config_service.py`: Configuration management. + - `docker_service.py`: Docker container management. +- **CLI**: User interface to control the application. --- -Pour plus de détails, consulter le reste de la documentation ou le code source. +For more details, see the rest of the documentation or the source code. diff --git a/pyproject.toml b/pyproject.toml index 42a24cb..19d1d46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "github-runner-manager" version = "0.1.0" description = "GitHub Runner Manager CLI for Docker-based GitHub Actions Runners" -authors = ["Grégory LEFER "] +authors = ["Grégory LEFER "] readme = "README.md" packages = [{include = "src"}] diff --git a/runners_config.yaml.dist b/runners_config.yaml.dist index 85706a5..f273d85 100644 --- a/runners_config.yaml.dist +++ b/runners_config.yaml.dist @@ -1,8 +1,8 @@ -# Configuration globale des runners GitHub +# Global configuration for GitHub runners runners_defaults: - base_image: ghcr.io/actions/actions-runner:2.328.0 # Image de base commune + base_image: ghcr.io/actions/actions-runner:2.328.0 # Common base image org_url: https://github.com/it-room - # Le token GitHub doit être défini dans la variable d'environnement GITHUB_TOKEN (voir .env) + # The GitHub token must be set in the GITHUB_TOKEN environment variable (see .env) runners: - id: sample @@ -14,9 +14,9 @@ runners: techno_version: 8.3 scheduler: - check_interval: "15s" # Fréquence de vérification des mises à jour - time_window: "07:00-23:00" # Plage horaire autorisée pour les actions automatiques - days: ["mon", "wed", "fri"] # Jours d'exécution + check_interval: "15s" # Update check frequency + time_window: "07:00-23:00" # Allowed time window for automatic actions + days: ["mon", "wed", "fri"] # Execution days actions: - check - build @@ -25,20 +25,20 @@ scheduler: webhooks: - # Configuration générale des webhooks + # General webhook configuration enabled: true - timeout: 10 # Délai d'attente en secondes global + timeout: 10 # Global timeout in seconds retry_count: 3 - retry_delay: 5 # Secondes + retry_delay: 5 # Seconds - # Configuration spécifique pour Slack + # Slack specific configuration 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 + timeout: 10 # Slack-specific timeout - # Liste des événements à notifier + # List of events to notify events: - runner_started - runner_stopped @@ -49,108 +49,108 @@ webhooks: - update_available - update_applied - # Templates de messages pour différents événements + # Message templates for different events templates: - # Template par défaut (utilisé si pas de template spécifique) + # Default template (used if no specific template) default: - title: "Notification GitHub Runner Manager" - text: "Un événement s'est produit" - color: "#36a64f" # Vert + title: "GitHub Runner Manager Notification" + text: "An event has occurred" + color: "#36a64f" # Green use_attachment: true fields: [] - # Runner démarré + # Runner started runner_started: - title: "Runner démarré" - text: "Le runner *{runner_name}* a été démarré avec succès" - color: "#36a64f" # Vert + title: "Runner Started" + text: "Runner *{runner_name}* started successfully" + color: "#36a64f" # Green fields: - name: "Labels" value: "{labels}" short: true - # Runner arrêté + # Runner stopped runner_stopped: - title: "Runner arrêté" - text: "Le runner *{runner_name}* a été arrêté" + title: "Runner Stopped" + text: "Runner *{runner_name}* has been stopped" color: "#ff9000" # Orange fields: [] - # Erreur runner + # Runner error runner_error: - title: "⚠️ Erreur Runner" - text: "Une erreur s'est produite avec le runner *{runner_name}*" - color: "#ff0000" # Rouge + title: "⚠️ Runner Error" + text: "An error occurred with runner *{runner_name}*" + color: "#ff0000" # Red fields: - name: "Runner ID" value: "{runner_id}" short: true - - name: "Erreur" + - name: "Error" value: "```{error_message}```" short: false - # Build commencé + # Build started build_started: - title: "Build démarré" - text: "Construction de l'image *{image_name}* démarrée" - color: "#3AA3E3" # Bleu + title: "Build Started" + text: "Image build *{image_name}* started" + color: "#3AA3E3" # Blue fields: - name: "Base image" value: "{base_image}" short: true - - name: "Technologie" + - name: "Technology" value: "{techno} {techno_version}" short: true - # Build terminé + # Build completed build_completed: - title: "Build terminé" - text: "Construction de l'image *{image_name}* terminée avec succès en {duration}s" - color: "#36a64f" # Vert + title: "Build Completed" + text: "Image build *{image_name}* completed successfully in {duration}s" + color: "#36a64f" # Green fields: - name: "Image" value: "{image_name}" short: true - - name: "Taille" + - name: "Size" value: "{image_size}" short: true - # Build échoué + # Build failed build_failed: - title: "⚠️ Build échoué" - text: "La construction de l'image *{image_name}* a échoué" - color: "#ff0000" # Rouge + title: "⚠️ Build Failed" + text: "Image build *{image_name}* failed" + color: "#ff0000" # Red fields: - - name: "Erreur" + - name: "Error" value: "```{error_message}```" short: false - # Mise à jour disponible + # Update available update_available: - title: "Mise à jour disponible" - text: "Une mise à jour est disponible pour l'image *{image_name}*" - color: "#3AA3E3" # Bleu + title: "Update Available" + text: "An update is available for image *{image_name}*" + color: "#3AA3E3" # Blue fields: - - name: "Version actuelle" + - name: "Current version" value: "{current_version}" short: true - - name: "Nouvelle version" + - name: "New version" value: "{available_version}" short: true - # Mise à jour appliquée + # Update applied update_applied: - title: "Mise à jour appliquée" - text: "L'image *{image_name}* a été mise à jour avec succès" - color: "#36a64f" # Vert + title: "Update Applied" + text: "Image *{image_name}* has been updated successfully" + color: "#36a64f" # Green fields: - - name: "Ancienne version" + - name: "Old version" value: "{old_version}" short: true - - name: "Nouvelle version" + - name: "New version" value: "{new_version}" short: true - - name: "Runners impactés" + - name: "Affected runners" 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 index dedfb2a..c084156 100644 --- a/src/notifications/channels/base.py +++ b/src/notifications/channels/base.py @@ -1,4 +1,4 @@ -"""Canaux de notifications (interface + registry).""" +"""Notification channels (interface + registry).""" from __future__ import annotations diff --git a/src/notifications/channels/webhook.py b/src/notifications/channels/webhook.py index 095551e..0bec03b 100644 --- a/src/notifications/channels/webhook.py +++ b/src/notifications/channels/webhook.py @@ -1,4 +1,4 @@ -"""Canal de notification via le service Webhook existant.""" +"""Notification channel via existing Webhook service.""" from __future__ import annotations diff --git a/src/notifications/dispatcher.py b/src/notifications/dispatcher.py index a9510eb..e892e35 100644 --- a/src/notifications/dispatcher.py +++ b/src/notifications/dispatcher.py @@ -1,4 +1,4 @@ -"""Dispatcher qui route les événements vers les canaux enregistrés.""" +"""Dispatcher that routes events to registered channels.""" from __future__ import annotations diff --git a/src/presentation/cli/commands.py b/src/presentation/cli/commands.py index f0fa653..5ff9db9 100644 --- a/src/presentation/cli/commands.py +++ b/src/presentation/cli/commands.py @@ -20,11 +20,11 @@ notification_service = NotificationService(config_service, console) app = typer.Typer( - help="GitHub Runner Manager - Gérez vos GitHub Actions runners Docker" + help="GitHub Runner Manager - Manage your GitHub Actions Docker runners" ) console = Console() -webhook_app = typer.Typer(help="Commandes pour tester et déboguer les webhooks") +webhook_app = typer.Typer(help="Commands to test and debug webhooks") @app.command() @@ -37,16 +37,16 @@ def build_runners_images(quiet: bool = False, progress: bool = True) -> None: for built in result.get("built", []): console.print( - f"[green][SUCCESS] Image {built['image']} buildée depuis {built['dockerfile']}[/green]" + f"[green][SUCCESS] Image {built['image']} built from {built['dockerfile']}[/green]" ) for skipped in result.get("skipped", []): console.print( - f"[yellow][INFO] Pas d'image à builder pour {skipped['id']} ({skipped['reason']})[/yellow]" + f"[yellow][INFO] No image to build for {skipped['id']} ({skipped['reason']})[/yellow]" ) for error in result.get("errors", []): - console.print(f"[red][ERREUR] {error['id']}: {error['reason']}[/red]") + console.print(f"[red][ERROR] {error['id']}: {error['reason']}[/red]") notification_service.notify_from_docker_result("build", result) @@ -58,7 +58,7 @@ def start_runners() -> None: for started in result.get("started", []): console.print( - f"[green][INFO] Runner {started['name']} démarré avec succès.[/green]" + f"[green][INFO] Runner {started['name']} started successfully.[/green]" ) notification_service.notify_runner_started( { @@ -72,7 +72,7 @@ def start_runners() -> None: for restarted in result.get("restarted", []): console.print( - f"[yellow][INFO] Runner {restarted['name']} existant mais stoppé. Redémarrage...[/yellow]" + f"[yellow][INFO] Runner {restarted['name']} exists but stopped. Restarting...[/yellow]" ) notification_service.notify_runner_started( @@ -84,16 +84,16 @@ def start_runners() -> None: for running in result.get("running", []): console.print( - f"[yellow][INFO] Runner {running['name']} déjà démarré. Rien à faire.[/yellow]" + f"[yellow][INFO] Runner {running['name']} is already running. Nothing to do.[/yellow]" ) for removed in result.get("removed", []): console.print( - f"[yellow][INFO] Container {removed['name']} n'est plus requis et a été supprimé.[/yellow]" + f"[yellow][INFO] Container {removed['name']} is no longer required and has been removed.[/yellow]" ) for error in result.get("errors", []): - console.print(f"[red][ERREUR] {error['id']}: {error['reason']}[/red]") + console.print(f"[red][ERROR] {error['id']}: {error['reason']}[/red]") notification_service.notify_runner_error( { "runner_id": error.get("id", ""), @@ -110,7 +110,7 @@ def stop_runners() -> None: for stopped in result.get("stopped", []): console.print( - f"[green][INFO] Runner {stopped['name']} arrêté avec succès.[/green]" + f"[green][INFO] Runner {stopped['name']} stopped successfully.[/green]" ) notification_service.notify_runner_stopped( { @@ -121,12 +121,10 @@ def stop_runners() -> None: ) for skipped in result.get("skipped", []): - console.print( - f"[yellow][INFO] {skipped['name']} n'est pas en cours d'exécution.[/yellow]" - ) + console.print(f"[yellow][INFO] {skipped['name']} is not running.[/yellow]") for error in result.get("errors", []): - console.print(f"[red][ERREUR] {error['name']}: {error['reason']}[/red]") + console.print(f"[red][ERROR] {error['name']}: {error['reason']}[/red]") notification_service.notify_runner_error( { "runner_id": error.get("id", ""), @@ -142,7 +140,7 @@ def remove_runners() -> None: result = docker_service.remove_runners() 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]") + console.print(f"[green][INFO] Runner {name} removed successfully.[/green]") notification_service.notify_runner_removed( {"runner_id": deleted.get("id", name), "runner_name": name} ) @@ -150,7 +148,7 @@ def remove_runners() -> None: for removed in result.get("removed", []): if "container" in removed: name = removed.get("container") - console.print(f"[green][INFO] Runner {name} supprimé avec succès.[/green]") + console.print(f"[green][INFO] Runner {name} removed successfully.[/green]") notification_service.notify_runner_removed( {"runner_id": removed.get("id", name), "runner_name": name} ) @@ -162,11 +160,11 @@ def remove_runners() -> None: console.print(f"[yellow][INFO] {name} {reason}.[/yellow]") else: console.print( - f"[yellow][INFO] {name} n'est pas disponible à la suppression.[/yellow]" + f"[yellow][INFO] {name} is not available for removal.[/yellow]" ) for error in result.get("errors", []): - console.print(f"[red][ERREUR] {error['name']}: {error['reason']}[/red]") + console.print(f"[red][ERROR] {error['name']}: {error['reason']}[/red]") notification_service.notify_runner_error( { "runner_id": error.get("id", ""), @@ -194,13 +192,13 @@ def check_base_image_update() -> None: if not result.get("update_available"): console.print( - f"[green]L'image runner est déjà à jour : v{result['current_version']}[/green]" + f"[green]The runner image is up to date : v{result['current_version']}[/green]" ) return console.print( - f"[yellow]Nouvelle version disponible : {result['latest_version']} " - f"(actuelle : {result['current_version']})[/yellow]" + f"[yellow]New version available : {result['latest_version']} " + f"(current : {result['current_version']})[/yellow]" ) notification_service.notify_update_available( @@ -213,14 +211,12 @@ def check_base_image_update() -> None: ) if typer.confirm( - f"Mettre à jour base_image vers la version {result['latest_version']} dans runners_config.yaml ?" + f"Update base_image to version {result['latest_version']} in runners_config.yaml?" ): update_result = docker_service.check_base_image_update(auto_update=True) if update_result.get("error"): - console.print( - f"[red]Erreur lors de la mise à jour: {update_result['error']}[/red]" - ) + console.print(f"[red]Error updating: {update_result['error']}[/red]") notification_service.notify_update_error( { @@ -231,7 +227,7 @@ def check_base_image_update() -> None: ) elif update_result.get("updated"): console.print( - f"[green]base_image mis à jour vers {update_result['new_image']} dans runners_config.yaml[/green]" + f"[green]base_image updated to {update_result['new_image']} in runners_config.yaml[/green]" ) notification_service.notify_image_updated( @@ -244,7 +240,7 @@ def check_base_image_update() -> None: ) if typer.confirm( - f"Voulez-vous builder les images des runners avec la nouvelle image {update_result.get('new_image')} ?" + f"Do you want to build the runner images with the new image {update_result.get('new_image')}?" ): # use progress bar for interactive post-update builds build_result = docker_service.build_runner_images( @@ -253,29 +249,29 @@ def check_base_image_update() -> None: for built in build_result.get("built", []): console.print( - f"[green][SUCCESS] Image {built['image']} buildée depuis {built['dockerfile']}[/green]" + f"[green][SUCCESS] Image {built['image']} built from {built['dockerfile']}[/green]" ) for skipped in build_result.get("skipped", []): console.print( - f"[yellow][INFO] Pas d'image à builder pour {skipped['id']} ({skipped['reason']})[/yellow]" + f"[yellow][INFO] No image to build for {skipped['id']} ({skipped['reason']})[/yellow]" ) for error in build_result.get("errors", []): console.print( - f"[red][ERREUR] {error['id']}: {error['reason']}[/red]" + f"[red][ERROR] {error['id']}: {error['reason']}[/red]" ) notification_service.notify_from_docker_result("build", build_result) if build_result.get("built"): if typer.confirm( - "Voulez-vous déployer (démarrer) les nouveaux containers avec ces images ?" + "Do you want to deploy (start) the new containers with these images?" ): start_result = docker_service.start_runners() for started in start_result.get("started", []): console.print( - f"[green][INFO] Runner {started['name']} démarré avec succès.[/green]" + f"[green][INFO] Runner {started['name']} started successfully.[/green]" ) notification_service.notify_runner_started( @@ -290,8 +286,8 @@ def check_base_image_update() -> None: for restarted in start_result.get("restarted", []): console.print( - f"[yellow][INFO] Runner {restarted['name']} existant mais stoppé." - f" Redémarrage...[/yellow]" + f"[yellow][INFO] Runner {restarted['name']} existed but stopped." + f" Restarting...[/yellow]" ) notification_service.notify_runner_started( { @@ -308,18 +304,18 @@ def check_base_image_update() -> None: for running in start_result.get("running", []): console.print( - f"[yellow][INFO] Runner {running['name']} déjà démarré. Rien à faire.[/yellow]" + f"[yellow][INFO] Runner {running['name']} already started. Nothing to do.[/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]" + f"[yellow][INFO] Container {removed['name']} is no longer required " + f"and has been removed.[/yellow]" ) for error in start_result.get("errors", []): console.print( - f"[red][ERREUR] {error['id']}: {error['reason']}[/red]" + f"[red][ERROR] {error['id']}: {error['reason']}[/red]" ) notification_service.notify_runner_error( { @@ -333,7 +329,7 @@ def check_base_image_update() -> None: } ) else: - console.print("[yellow]Mise à jour annulée.[/yellow]") + console.print("[yellow]Update canceled.[/yellow]") @app.command() @@ -398,7 +394,7 @@ def list_runners() -> None: "-", str(idx), name, - "[red]⚠ sera supprimé[/red]", + "[red]⚠ will be removed[/red]", "", ) @@ -406,7 +402,7 @@ def list_runners() -> None: table.add_row("", "", "", "", "", "") table.caption = ( - f"[bold blue]Total runners actifs : {total_running} / {total_count}[/bold blue]" + f"[bold blue]Total active runners: {total_running} / {total_count}[/bold blue]" ) console.print(table) @@ -415,22 +411,24 @@ def list_runners() -> None: def scheduler() -> None: """Start the scheduler for automated task execution according to the configuration.""" try: - # Utilisation du service externe pour gérer le scheduler scheduler_service.start() except KeyboardInterrupt: - console.print("[yellow]Scheduler arrêté manuellement.[/yellow]") + console.print("[yellow]Scheduler stopped manually.[/yellow]") scheduler_service.stop() except Exception as e: - console.print(f"[red]Erreur dans le scheduler: {str(e)}[/red]") + console.print(f"[red]Error in 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" + None, + "--event", + "-e", + help="Type of event to simulate (if not provided, an interactive menu will be shown)", ), provider: str = typer.Option( - None, "--provider", "-p", help="Provider webhook spécifique à utiliser" + None, "--provider", "-p", help="Specific webhook provider to use" ), ) -> None: """ @@ -447,7 +445,7 @@ def webhook_test( @webhook_app.command("test-all") def webhook_test_all( provider: str = typer.Option( - None, "--provider", "-p", help="Provider webhook spécifique à tester" + None, "--provider", "-p", help="Specific webhook provider to test" ) ) -> None: """ @@ -459,10 +457,7 @@ def webhook_test_all( 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" -) +app.add_typer(webhook_app, name="webhook", help="Commands to test and debug webhooks") if __name__ == "__main__": # pragma: no cover app() diff --git a/src/presentation/cli/webhook_commands.py b/src/presentation/cli/webhook_commands.py index e4ff362..726ba52 100644 --- a/src/presentation/cli/webhook_commands.py +++ b/src/presentation/cli/webhook_commands.py @@ -1,4 +1,6 @@ -"""Commande de débogage et test des webhooks.""" +""" +Webhook-related CLI commands for testing and debugging. +""" import datetime import json @@ -15,7 +17,7 @@ class MockEvent(str, Enum): - """Types d'événements pour les simulations de tests webhook.""" + """Events types for webhook test simulations.""" RUNNER_STARTED = "runner_started" RUNNER_STOPPED = "runner_stopped" @@ -27,45 +29,44 @@ class MockEvent(str, Enum): 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", + "runner_name": "my-runner-php83-1", + "labels": "my-runner-set-php83, php8.3", "techno": "php", "techno_version": "8.3", }, MockEvent.RUNNER_STOPPED: { "runner_id": "php83-1", - "runner_name": "itroom-runner-php83-1", + "runner_name": "my-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.", + "runner_name": "my-runner-php83-1", + "error_message": "The runner could not register with GitHub: invalid token.", }, MockEvent.BUILD_STARTED: { - "image_name": "itroom-runner-php83", + "image_name": "my-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", + "image_name": "my-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", + "image_name": "my-runner-php83", + "error_message": "Error during step 3/8: npm install failed with code 1", }, MockEvent.UPDATE_AVAILABLE: { "image_name": "actions-runner", "current_version": "2.328.0", "new_version": "2.329.0", - "auto_update": "Activé", + "auto_update": "Enabled", }, MockEvent.UPDATE_APPLIED: { "image_name": "actions-runner", @@ -84,102 +85,88 @@ def test_webhooks( console: Optional[Console] = None, ) -> Dict[str, Any]: """ - Teste l'envoi de webhooks avec des données simulées. + Test sending webhooks with simulated data. 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 + config_service: Configuration service + event_type: Event type to simulate (if None, a menu will be displayed) + provider: Specific webhook provider to use + (If None, all configured providers will be used) + interactive: Interactive mode with confirmation and detailed display + console: Rich Console for display Returns: - Résultat de l'opération avec statuts pour chaque provider + Result of the operation with statuses for each 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]" + "[red]No webhook configuration found in runners_config.yaml[/red]" ) - return {"error": "Aucune configuration webhook trouvée"} + return {"error": "No webhook configuration found"} - # 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é"} + console.print("[red]No webhook provider is enabled in the configuration[/red]") + return {"error": "No provider enabled"} - # Liste des providers disponibles available_providers = list(webhook_service.providers.keys()) - # Afficher les providers disponibles if interactive: - console.print("[green]Providers webhook disponibles:[/green]") + console.print("[green]Available webhook providers:[/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", + "Choose an event type to simulate", 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"} + console.print(f"[red]Invalid event type '{event_type}'[/red]") + return {"error": f"Invalid event type '{event_type}'"} - # 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)", + "Choose a specific provider (leave blank for all)", 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")) + console.print("\n[yellow]Simulation data to be sent:[/yellow]") + console.print(Panel(json.dumps(mock_data, indent=2), title="Data")) - # Demander confirmation - if not typer.confirm("Envoyer cette notification webhook?"): - console.print("[yellow]Envoi annulé[/yellow]") + # Ask for confirmation + if not typer.confirm("Send this webhook notification?"): + console.print("[yellow]Sending cancelled[/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]") + console.print("\n[bold]Results of the sending:[/bold]") for provider_name, success in results.items(): if success: console.print( - f"[green]✅ {provider_name}: Notification envoyée avec succès[/green]" + f"[green]✅ {provider_name}: Notification sent successfully[/green]" ) else: - console.print(f"[red]❌ {provider_name}: Échec de l'envoi[/red]") + console.print(f"[red]❌ {provider_name}: Sending failed[/red]") return { "event_type": event_type, @@ -195,47 +182,41 @@ def debug_test_all_templates( console: Optional[Console] = None, ) -> Dict[str, Any]: """ - Teste tous les templates configurés pour un provider ou tous les providers. + Test all templates configured for a provider or all 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 + config_service: Configuration service + provider: Specific provider to test (if None, all providers will be tested) + console: Rich Console for display Returns: - Résultats des tests pour chaque template + Test results for each 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]" + "[red]No webhook configuration found in runners_config.yaml[/red]" ) - return {"error": "Aucune configuration webhook trouvée"} + return {"error": "No webhook configuration found"} - # 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é"} + console.print("[red]No webhook provider is enabled in the configuration[/red]") + return {"error": "No provider enabled"} - # 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]" + f"[yellow]Provider '{provider_name}' not configured, skipping[/yellow]" ) continue @@ -243,22 +224,18 @@ def debug_test_all_templates( provider_events = provider_config.get("events", []) console.print( - f"\n[bold blue]Test des templates pour {provider_name}:[/bold blue]" + f"\n[bold blue]Testing templates for {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]") + console.print(f"\n[yellow]Testing 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 ) @@ -267,13 +244,13 @@ def debug_test_all_templates( if success: console.print( - f"[green]✅ {event_type}: Notification envoyée avec succès[/green]" + f"[green]✅ {event_type}: Notification sent successfully[/green]" ) else: - console.print(f"[red]❌ {event_type}: Échec de l'envoi[/red]") + console.print(f"[red]❌ {event_type}: Sending failed[/red]") else: console.print( - f"[dim]Template '{event_type}' non configuré pour {provider_name}, ignoré[/dim]" + f"[dim]Template '{event_type}' not configured for {provider_name}, skipping[/dim]" ) results[provider_name] = provider_results diff --git a/src/services/__init__.py b/src/services/__init__.py index b67ee55..643d2b0 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -1,4 +1,4 @@ -"""Point d'entrée pour les services partagés.""" +"""Entrypoint for services package.""" from src.services.config_service import ConfigService from src.services.docker_service import DockerService diff --git a/src/services/config_schema.py b/src/services/config_schema.py index 4952b92..971c1d3 100644 --- a/src/services/config_schema.py +++ b/src/services/config_schema.py @@ -23,38 +23,37 @@ class WebhookConfig(BaseModel): events: List[str] = [] -# Définitions communes pour les champs de notification class NotificationField(BaseModel): """Configuration for a field in a notification.""" name: str value: str - short: bool = True # Pour Slack - inline: bool = True # Pour Discord + short: bool = True # For Slack + inline: bool = True # For Discord -# Templates pour Slack +# Templates for Slack class SlackTemplateConfig(BaseModel): """Template for a Slack notification.""" title: str text: str - color: str = "#36a64f" # Vert par défaut + color: str = "#36a64f" # Green by default use_attachment: bool = True fields: List[NotificationField] = [] -# Templates pour Discord +# Templates for Discord class DiscordTemplateConfig(BaseModel): """Template for a Discord notification.""" title: str description: str - color: int = 3066993 # Vert en décimal + color: int = 3066993 # Green in decimal fields: List[NotificationField] = [] -# Section pour Teams +# Section for Teams class TeamsSection(BaseModel): """Section in a Microsoft Teams card.""" @@ -67,7 +66,7 @@ class TeamsTemplateConfig(BaseModel): """Template for a Microsoft Teams notification.""" title: str - themeColor: str = "0076D7" # Bleu par défaut + themeColor: str = "0076D7" # Blue by default sections: List[TeamsSection] = [] @@ -77,7 +76,7 @@ class SlackConfig(BaseModel): enabled: bool = False webhook_url: HttpUrl - channel: str = "" # Optionnel, peut être défini dans l'URL du webhook + channel: str = "" # Optional, can be defined in the webhook URL username: str = "GitHub Runner Manager" timeout: int = 10 events: List[str] = [] diff --git a/src/services/docker_service.py b/src/services/docker_service.py index ccac0ee..0d04c82 100644 --- a/src/services/docker_service.py +++ b/src/services/docker_service.py @@ -329,7 +329,7 @@ def build_runner_images( continue try: - image_tag = f"itroom/{techno}:{techno_version}-{runner_version}" + image_tag = f"{techno}:{techno_version}-{runner_version}" build_dir = os.path.dirname(build_image) or "." dockerfile_path = build_image @@ -401,7 +401,7 @@ def start_runners(self) -> dict: base_image = getattr(runner, "base_image", base_image_default) org_url = getattr(runner, "org_url", org_url_default) if build_image and techno and techno_version: - image = f"itroom/{techno}:{techno_version}-{runner_version}" + image = f"{techno}:{techno_version}-{runner_version}" else: image = f"{prefix}:latest" diff --git a/src/services/scheduler_service.py b/src/services/scheduler_service.py index c36b2bd..4096259 100644 --- a/src/services/scheduler_service.py +++ b/src/services/scheduler_service.py @@ -1,4 +1,4 @@ -"""Service pour la gestion des tâches planifiées.""" +"""Service for managing scheduled tasks in GitHub Runner Manager.""" from __future__ import annotations @@ -15,7 +15,7 @@ class SchedulerService: - """Service de gestion des tâches planifiées pour GitHub Runner Manager.""" + """Service for managing scheduled tasks in GitHub Runner Manager.""" def __init__( self, @@ -39,10 +39,10 @@ def __init__( self.schedule_days = [] def load_config(self) -> bool: - """Charge et valide la configuration du scheduler. + """Load and validate the scheduler configuration. Returns: - bool: True si la configuration est valide et le scheduler est activé, False sinon + bool: True if the configuration is valid and the scheduler is enabled, False otherwise """ try: config = self.config_service.load_config() @@ -61,21 +61,19 @@ def load_config(self) -> bool: return self._validate_config() except Exception as e: - self.console.print( - f"[red]Erreur lors du chargement de la configuration: {str(e)}[/red]" - ) + self.console.print(f"[red]Error loading configuration: {str(e)}[/red]") return False def _validate_config(self) -> bool: - """Valide les paramètres de configuration. + """Validate configuration parameters. Returns: - bool: True si la configuration est valide, False sinon + bool: True if the configuration is valid, False otherwise """ interval_match = re.match(r"(\d+)([smh])", self.check_interval) if not interval_match: self.console.print( - f"[red]Format d'intervalle invalide: {self.check_interval}[/red]" + f"[red]Invalid interval format: {self.check_interval}[/red]" ) return False @@ -85,7 +83,7 @@ def _validate_config(self) -> bool: window_match = re.match(r"(\d{2}):(\d{2})-(\d{2}):(\d{2})", self.time_window) if not window_match: self.console.print( - f"[red]Format de plage horaire invalide: {self.time_window}[/red]" + f"[red]Invalid time window format: {self.time_window}[/red]" ) return False @@ -111,159 +109,154 @@ def _validate_config(self) -> bool: ] if not self.schedule_days: - self.console.print("[red]Aucun jour valide configuré.[/red]") + self.console.print("[red]No valid day configured.[/red]") return False return True def check_time_window(self) -> bool: - """Vérifie si l'heure actuelle est dans la plage autorisée. + """Check if the current time is within the allowed time window. Returns: - bool: True si l'heure actuelle est dans la plage autorisée, False sinon + bool: True if the current time is within the allowed time window, False otherwise """ now = datetime.datetime.now().time() return self.start_time <= now <= self.end_time def run_scheduled_tasks(self) -> None: - """Exécute les tâches planifiées selon la configuration.""" + """Run scheduled tasks according to the configuration.""" if not self.check_time_window(): self.console.print( - "[yellow]Hors plage horaire autorisée - Tâche reportée[/yellow]" + "[yellow]Outside allowed time window - Task postponed[/yellow]" ) return now = datetime.datetime.now() self.console.print( - f"\n[blue]Exécution des actions planifiées à {now.strftime('%H:%M:%S')}[/blue]" + f"\n[blue]Executing scheduled actions at {now.strftime('%H:%M:%S')}[/blue]" ) try: self._execute_actions() if self.retry_count == 0: - self.console.print("[green]Exécution terminée avec succès[/green]") + self.console.print("[green]Execution completed successfully[/green]") if self.retry_count >= self.max_retries: self.console.print( - f"[red]Nombre maximal de tentatives atteint ({self.max_retries}). " - f"Arrêt du scheduler.[/red]" + f"[red]Maximum retry count reached ({self.max_retries}). " + f"Stopping scheduler.[/red]" ) self.stop() except Exception as e: self.console.print( - f"[red]Erreur pendant l'exécution des tâches: {str(e)}[/red]" + f"[red]Error occurred while executing tasks: {str(e)}[/red]" ) self.retry_count += 1 if self.retry_count >= self.max_retries: self.console.print( - f"[red]Nombre maximal de tentatives atteint ({self.max_retries}). " - f"Arrêt du scheduler.[/red]" + f"[red]No maximum retry count reached ({self.max_retries}). " + f"Stopping scheduler.[/red]" ) self.stop() def _execute_actions(self) -> None: - """Exécute les actions configurées.""" + """Run the configured actions.""" if "check" in self.actions: self._execute_check_action() def _execute_check_action(self) -> None: - """Exécute l'action de vérification des mises à jour d'image.""" - self.console.print( - "[cyan]Action: Vérification des mises à jour d'image runner[/cyan]" - ) + """Run the action to check for image updates.""" + self.console.print("[cyan]Action: Check for image updates[/cyan]") check_result = self.docker_service.check_base_image_update( auto_update="build" in self.actions ) if check_result.get("error"): - self.console.print(f"[red]Erreur: {check_result['error']}[/red]") + self.console.print(f"[red]Error: {check_result['error']}[/red]") self.retry_count += 1 return if not check_result.get("update_available"): self.console.print( - f"[green]L'image runner est déjà à jour : v{check_result['current_version']}[/green]" + f"[green]The runner image is up to date: v{check_result['current_version']}[/green]" ) self.retry_count = 0 return self.console.print( - f"[yellow]Nouvelle version disponible : {check_result['latest_version']} " - f"(actuelle : {check_result['current_version']})[/yellow]" + f"[yellow]New version available: {check_result['latest_version']} " + f"(current: {check_result['current_version']})[/yellow]" ) if "build" in self.actions and check_result.get("updated"): self._execute_build_action(check_result) def _execute_build_action(self, check_result: Dict[str, Any]) -> None: - """Exécute l'action de construction des images. + """Run the action to build images. Args: - check_result: Résultat de la vérification des mises à jour + check_result: Result of the update check """ self.console.print( - f"[green]base_image mise à jour vers {check_result['new_image']} " - f"dans runners_config.yaml[/green]" + f"[green]base_image updated to {check_result['new_image']} " + f"in runners_config.yaml[/green]" ) - self.console.print("[cyan]Action: Reconstruction des images runner[/cyan]") + self.console.print("[cyan]Action: Rebuilding runner images[/cyan]") build_result = self.docker_service.build_runner_images( quiet=True, use_progress=False ) for built in build_result.get("built", []): self.console.print( - f"[green][SUCCESS] Image {built['image']} buildée depuis {built['dockerfile']}[/green]" + f"[green][SUCCESS] Image {built['image']} built from {built['dockerfile']}[/green]" ) for skipped in build_result.get("skipped", []): self.console.print( - f"[yellow][INFO] Pas d'image à builder pour {skipped['id']} " + f"[yellow][INFO] No image to build for {skipped['id']} " f"({skipped['reason']})[/yellow]" ) for error in build_result.get("errors", []): - self.console.print(f"[red][ERREUR] {error['id']}: {error['reason']}[/red]") + self.console.print(f"[red][ERROR] {error['id']}: {error['reason']}[/red]") self.retry_count += 1 - # Déploiement automatique si demandé et si des images ont été construites if "deploy" in self.actions and build_result.get("built"): - self.console.print( - "[cyan]Action: Déploiement automatique des runners[/cyan]" - ) + self.console.print("[cyan]Action: Automatic deployment of runners[/cyan]") try: start_result = self.docker_service.start_runners() for started in start_result.get("started", []): self.console.print( - f"[green][INFO] Runner {started['name']} démarré (deploy).[/green]" + f"[green][INFO] Runner {started['name']} started (deploy).[/green]" ) for restarted in start_result.get("restarted", []): self.console.print( - f"[yellow][INFO] Runner {restarted['name']} redémarré (deploy).[/yellow]" + f"[yellow][INFO] Runner {restarted['name']} restarted (deploy).[/yellow]" ) for running in start_result.get("running", []): self.console.print( - f"[blue][INFO] Runner {running['name']} déjà en cours (deploy).[/blue]" + f"[blue][INFO] Runner {running['name']} already running (deploy).[/blue]" ) for removed in start_result.get("removed", []): self.console.print( - f"[magenta][INFO] Container {removed['name']} supprimé (deploy).[/magenta]" + f"[magenta][INFO] Container {removed['name']} removed (deploy).[/magenta]" ) for error in start_result.get("errors", []): self.console.print( - f"[red][ERREUR] {error['id']}: {error['reason']} (deploy)[/red]" + f"[red][ERROR] {error['id']}: {error['reason']} (deploy)[/red]" ) except Exception as e: self.console.print( - f"[red]Erreur lors du déploiement automatique: {str(e)}[/red]" + f"[red]Error during automatic deployment: {str(e)}[/red]" ) self.retry_count += 1 def _setup_schedule(self) -> None: - """Configure les tâches planifiées avec la bibliothèque schedule.""" + """Configure scheduled tasks with the schedule library.""" schedule.clear() unit_map = { "s": lambda: schedule.every(self.interval_value).seconds, @@ -292,16 +285,16 @@ def _setup_schedule(self) -> None: self._jobs.append(job) def start(self) -> None: - """Démarre le scheduler.""" + """Starts the scheduler.""" if not self.load_config(): return self._setup_schedule() - self.console.print("[blue]Scheduler démarré:[/blue]") - self.console.print(f" [cyan]Intervalle:[/cyan] {self.check_interval}") - self.console.print(f" [cyan]Plage horaire:[/cyan] {self.time_window}") - self.console.print(f" [cyan]Jours:[/cyan] {', '.join(self.allowed_days)}") + self.console.print("[blue]Scheduler started:[/blue]") + self.console.print(f" [cyan]Interval:[/cyan] {self.check_interval}") + self.console.print(f" [cyan]Time window:[/cyan] {self.time_window}") + self.console.print(f" [cyan]Days:[/cyan] {', '.join(self.allowed_days)}") self.console.print(f" [cyan]Actions:[/cyan] {', '.join(self.actions)}") self.is_running = True @@ -311,14 +304,14 @@ def start(self) -> None: schedule.run_pending() time.sleep(1) except KeyboardInterrupt: - self.console.print("[yellow]Scheduler arrêté manuellement.[/yellow]") + self.console.print("[yellow]Scheduler stopped manually.[/yellow]") self.stop() except Exception as e: - self.console.print(f"[red]Erreur dans le scheduler: {str(e)}[/red]") + self.console.print(f"[red]Error in scheduler: {str(e)}[/red]") self.stop() def stop(self) -> None: - """Arrête le scheduler.""" + """Stops the scheduler.""" self.is_running = False schedule.clear() self._jobs = [] diff --git a/src/services/webhook_service.py b/src/services/webhook_service.py index 3fdfe84..ec1373e 100644 --- a/src/services/webhook_service.py +++ b/src/services/webhook_service.py @@ -45,10 +45,8 @@ def __init__(self, config: Dict[str, Any], console: Optional[Console] = None): 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() @@ -87,24 +85,21 @@ def notify( results = {} - # Filtrer les providers à utiliser + # Filter the provider if specified 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]" + f"[yellow]Provider webhook [bold]{provider}[/bold] not configured[/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 ) @@ -113,11 +108,11 @@ def notify( if success: self.console.print( f"[green]Notification [bold]{event_type}[/bold] " - f"envoyée via [bold]{provider_name}[/bold][/green]" + f"sent to [bold]{provider_name}[/bold][/green]" ) else: self.console.print( - f"[red]Échec de l'envoi de la notification [bold]" + f"[red]Failed to send notification [bold]" f"{event_type}[/bold] via [bold]{provider_name}[/bold][/red]" ) @@ -145,10 +140,9 @@ def _send_notification( try: webhook_url = config.get("webhook_url") if not webhook_url: - logger.error(f"URL webhook manquante pour le provider {provider}") + logger.error(f"Missing webhook URL for 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) @@ -157,14 +151,12 @@ def _send_notification( 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)}") + logger.exception(f"Error sending to provider {provider}: {str(e)}") return False def _send_with_retry( @@ -185,26 +177,23 @@ def _send_with_retry( 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}" + f"Attempt {attempt + 1}/{retry_count + 1}: " + f"Failed with status code {response.status_code}: {response.text}" ) - # Attendre avant de réessayer, sauf pour la dernière tentative + # Wait before retrying, except for the last attempt if attempt < retry_count: import time @@ -212,7 +201,7 @@ def _send_with_retry( except Exception as e: logger.warning( - f"Tentative {attempt + 1}/{retry_count + 1}: Exception: {str(e)}" + f"Attempt {attempt + 1}/{retry_count + 1}: Exception: {str(e)}" ) return False @@ -236,19 +225,16 @@ def _format_slack_payload( 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}", + "text": f"Event {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, @@ -258,7 +244,6 @@ def _format_slack_payload( "mrkdwn_in": ["text", "fields"], } - # Ajout des champs fields = template.get("fields", []) for field in fields: field_name = self._format_string(field.get("name", ""), data) @@ -269,14 +254,12 @@ def _format_slack_payload( {"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 @@ -301,19 +284,16 @@ def _format_discord_payload( 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 + "description": f"Event {event_type}", + "color": 3066993, } - # 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 + color = template.get("color", 3066993) - # Construction de l'embed embed = { "title": title, "description": description, @@ -322,7 +302,6 @@ def _format_discord_payload( "timestamp": datetime.now().isoformat(), } - # Ajout des champs fields = template.get("fields", []) for field in fields: field_name = self._format_string(field.get("name", ""), data) @@ -333,7 +312,6 @@ def _format_discord_payload( {"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", ""), @@ -360,31 +338,26 @@ def _format_teams_payload( 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": []}], + "themeColor": "0076D7", + "sections": [{"activityTitle": f"Event {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"]: @@ -401,7 +374,6 @@ def _format_teams_payload( sections.append(section) - # Message complet (Card adaptative) payload = { "@type": "MessageCard", "@context": "http://schema.org/extensions", @@ -427,7 +399,6 @@ def _format_generic_payload( Returns: Payload formatted for the generic webhook """ - # Message simple pour webhooks génériques payload = { "event_type": event_type, "timestamp": datetime.now().isoformat(), @@ -450,8 +421,8 @@ def _format_string(self, template_str: str, data: Dict[str, Any]) -> str: try: return template_str.format(**data) except KeyError as e: - logger.warning(f"Variable manquante dans le template: {e}") + logger.warning(f"Missing variable in template: {e}") return template_str except Exception as e: - logger.warning(f"Erreur lors du formatage: {e}") + logger.warning(f"Error formatting string: {e}") return template_str diff --git a/tests/cli/test_commands_build_start_stop_remove.py b/tests/cli/test_commands_build_start_stop_remove.py index 3eaac24..4cf9870 100644 --- a/tests/cli/test_commands_build_start_stop_remove.py +++ b/tests/cli/test_commands_build_start_stop_remove.py @@ -24,7 +24,7 @@ "skipped": [{"id": "x", "reason": "No build_image"}], "errors": [], }, - ["INFO", "Pas d'image", "No build_image"], + ["INFO", "No image", "No build_image"], ), ( { @@ -32,7 +32,7 @@ "skipped": [], "errors": [{"id": "x", "reason": "Build failed"}], }, - ["ERREUR", "x", "Build failed"], + ["ERROR", "x", "Build failed"], ), ( {"built": [], "skipped": [], "errors": []}, @@ -60,7 +60,7 @@ def test_build_runners_images(mock_build, cli, result_data, expected): "removed": [], "errors": [], }, - ["r1", "démarré"], + ["r1", "started"], ), ( { @@ -70,7 +70,7 @@ def test_build_runners_images(mock_build, cli, result_data, expected): "removed": [], "errors": [], }, - ["r2", "Redémarrage"], + ["r2", "Restarting"], ), ( { @@ -80,7 +80,7 @@ def test_build_runners_images(mock_build, cli, result_data, expected): "removed": [], "errors": [], }, - ["r3", "déjà démarré"], + ["r3", "already running"], ), ( { @@ -90,7 +90,7 @@ def test_build_runners_images(mock_build, cli, result_data, expected): "removed": [{"name": "old"}], "errors": [], }, - ["old", "n'est plus requis"], + ["old", "no longer required"], ), ( { @@ -100,7 +100,7 @@ def test_build_runners_images(mock_build, cli, result_data, expected): "removed": [], "errors": [{"id": "e", "reason": "fail"}], }, - ["ERREUR", "e", "fail"], + ["ERROR", "e", "fail"], ), ( { @@ -128,15 +128,15 @@ def test_start_runners(mock_start, cli, result_data, expected): [ ( {"stopped": [{"name": "r1"}], "skipped": [], "errors": []}, - ["r1", "arrêté"], + ["r1", "stopped"], ), ( {"stopped": [], "skipped": [{"name": "r2"}], "errors": []}, - ["r2", "n'est pas en cours"], + ["r2", "is not running"], ), ( {"stopped": [], "skipped": [], "errors": [{"name": "e", "reason": "fail"}]}, - ["ERREUR", "e", "fail"], + ["ERROR", "e", "fail"], ), ( {"stopped": [], "skipped": [], "errors": []}, @@ -158,13 +158,13 @@ def test_stop_runners(mock_stop, cli, result_data, expected): [ ( {"removed": [{"container": "c1"}], "skipped": [], "errors": []}, - ["c1", "supprimé avec succès"], + ["c1", "removed successfully"], [], ), ( {"removed": [{"name": "r"}], "skipped": [], "errors": []}, [], - ["supprimé avec succès"], # message container non attendu + ["removed successfully"], # message container non attendu ), ( { @@ -177,7 +177,7 @@ def test_stop_runners(mock_stop, cli, result_data, expected): ), ( {"removed": [], "skipped": [], "errors": [{"name": "e", "reason": "fail"}]}, - ["ERREUR", "e", "fail"], + ["ERROR", "e", "fail"], [], ), ( @@ -232,10 +232,9 @@ def test_check_base_image_update_build_outputs( res = cli.invoke(app, ["check-base-image-update"]) assert res.exit_code == 0 - # Assert skipped line (line ~158) content fragments - assert "Pas d'image à builder" in res.stdout + print(res.stdout) + assert "No image to build" in res.stdout assert "r1" in res.stdout - # Assert error line (line ~163) content fragments - assert "ERREUR" in res.stdout + assert "ERROR" in res.stdout assert "r2" in res.stdout assert "Build failed" in res.stdout diff --git a/tests/cli/test_commands_check_base_image_update.py b/tests/cli/test_commands_check_base_image_update.py index 6cb6aa1..08759b0 100644 --- a/tests/cli/test_commands_check_base_image_update.py +++ b/tests/cli/test_commands_check_base_image_update.py @@ -23,25 +23,25 @@ def strip_ansi_codes(text: str) -> str: {"current_version": "1", "latest_version": "1", "update_available": False}, False, None, - ["déjà à jour"], + ["The runner image is up to date"], ), ( {"current_version": "1", "latest_version": "2", "update_available": True}, False, None, - ["Nouvelle version", "Mise à jour annulée"], + ["New version available", "Update canceled"], ), ( {"current_version": "1", "latest_version": "2", "update_available": True}, True, {"updated": True, "new_image": "img:2"}, - ["mis à jour vers", "img:2"], + ["updated to", "img:2"], ), ( {"current_version": "1", "latest_version": "2", "update_available": True}, True, {"error": "write failed"}, - ["Erreur lors de la mise à jour", "write failed"], + ["Error updating", "write failed"], ), ( {"current_version": "1", "latest_version": "2", "update_available": True}, @@ -97,10 +97,10 @@ def test_check_base_image_update_decline_build( res = cli.invoke(app, ["check-base-image-update"]) assert res.exit_code == 0 clean_stdout = strip_ansi_codes(res.stdout) - assert "mis à jour vers" in clean_stdout + assert "updated to" in clean_stdout assert "img:2" in clean_stdout mock_build.assert_not_called() - assert "buildée depuis" not in clean_stdout + assert "built from" not in clean_stdout @patch("src.services.docker_service.DockerService.start_runners") @@ -111,14 +111,14 @@ def test_check_base_image_update_decline_build( "start_result, expected_snippets, deploy_confirm", [ ({}, [], False), - ({"started": [{"name": "runner-a"}]}, ["runner-a démarré avec succès"], True), + ({"started": [{"name": "runner-a"}]}, ["runner-a started successfully"], True), ( {"restarted": [{"name": "runner-b"}]}, - ["runner-b existant mais stoppé"], + ["runner-b existed but stopped"], True, ), - ({"running": [{"name": "runner-c"}]}, ["runner-c déjà démarré"], True), - ({"removed": [{"name": "runner-d"}]}, ["runner-d n'est plus requis"], True), + ({"running": [{"name": "runner-c"}]}, ["runner-c already started"], True), + ({"removed": [{"name": "runner-d"}]}, ["runner-d is no longer required"], True), ], ) def test_check_base_image_update_deploy_branches( @@ -218,8 +218,8 @@ def test_check_base_image_update_webhook_called( assert "{" not in val and "}" not in val assert any( - t == "Mise à jour disponible" for t in titles - ), f"Aucune notif 'Mise à jour disponible' dans: {titles}" + t == "Update Available" for t in titles + ), f"Notification 'Update Available' not found in : {titles}" def test_webhook_channel_removes_restarted_false(monkeypatch): diff --git a/tests/cli/test_commands_list_runners.py b/tests/cli/test_commands_list_runners.py index 201345c..d8116f9 100644 --- a/tests/cli/test_commands_list_runners.py +++ b/tests/cli/test_commands_list_runners.py @@ -141,7 +141,7 @@ ], "total": {"count": 1, "running": 1}, }, - ["sera supprimé", "g1-2"], + ["will be removed", "g1-2"], ), ( { @@ -167,7 +167,7 @@ ], "total": {"count": 1, "running": 1}, }, - ["sera supprimé", "g1-3", "g1-5"], + ["will be removed", "g1-3", "g1-5"], ), ( { @@ -224,7 +224,7 @@ ], "total": {"count": 0, "running": 0}, }, - ["sera supprimé", "g1-1", "g1-2"], + ["will be removed", "g1-1", "g1-2"], ), ], ) diff --git a/tests/cli/test_commands_scheduler.py b/tests/cli/test_commands_scheduler.py index 5f73234..db05154 100644 --- a/tests/cli/test_commands_scheduler.py +++ b/tests/cli/test_commands_scheduler.py @@ -27,7 +27,7 @@ def test_scheduler_keyboard_interrupt(self): scheduler_service.start.assert_called_once() scheduler_service.stop.assert_called_once() console.print.assert_called_once_with( - "[yellow]Scheduler arrêté manuellement.[/yellow]" + "[yellow]Scheduler stopped manually.[/yellow]" ) def test_scheduler_exception(self): @@ -40,5 +40,5 @@ def test_scheduler_exception(self): scheduler_service.start.assert_called_once() console.print.assert_called_once_with( - f"[red]Erreur dans le scheduler: {str(test_exception)}[/red]" + f"[red]Error in scheduler: {str(test_exception)}[/red]" ) diff --git a/tests/docker_service/test_core.py b/tests/docker_service/test_core.py index bf5c405..3f6db82 100644 --- a/tests/docker_service/test_core.py +++ b/tests/docker_service/test_core.py @@ -85,7 +85,7 @@ def test_build_image_happy_path(docker_service): client.api = api_client mock_docker.return_value = client docker_service.build_image( - image_tag="itroom/python:3.11-2.300.0", + image_tag="python:3.11-2.300.0", dockerfile_path="config/Dockerfile.node20", build_dir="config", build_args={"BASE_IMAGE": "ghcr.io/actions/runner:2.300.0"}, @@ -94,7 +94,7 @@ def test_build_image_happy_path(docker_service): args, kwargs = api_client.build.call_args assert kwargs["path"] == "config" assert kwargs["dockerfile"] == "Dockerfile.node20" - assert kwargs["tag"] == "itroom/python:3.11-2.300.0" + assert kwargs["tag"] == "python:3.11-2.300.0" assert kwargs["buildargs"] == {"BASE_IMAGE": "ghcr.io/actions/runner:2.300.0"} @@ -102,7 +102,7 @@ def test_run_container_command_building(docker_service): with patch.object(docker_service, "run_command") as mock_run: docker_service.run_container( name="r1", - image="itroom/python:3.11-2.300.0", + image="python:3.11-2.300.0", command="echo hi", env_vars={"A": "1", "B": "2"}, detach=True, @@ -113,7 +113,7 @@ def test_run_container_command_building(docker_service): assert "--name" in called_cmd and "r1" in called_cmd assert "--restart" in called_cmd and "always" in called_cmd assert "-e" in called_cmd and "A=1" in called_cmd and "B=2" in called_cmd - assert "itroom/python:3.11-2.300.0" in called_cmd + assert "python:3.11-2.300.0" in called_cmd assert ( "/bin/bash" in called_cmd and "-c" in called_cmd and "echo hi" in called_cmd ) diff --git a/tests/docker_service/test_logging.py b/tests/docker_service/test_logging.py index 239ff07..76581d9 100644 --- a/tests/docker_service/test_logging.py +++ b/tests/docker_service/test_logging.py @@ -19,7 +19,7 @@ def test_build_image_quiet_logger_filters(): "Step 3/12 : RUN echo ERROR inside step", "some intermediate output", "Successfully built deadbeef", - "Successfully tagged itroom/python:latest", + "Successfully tagged python:latest", "ERROR something failed", "SUCCESSFULLY starting deployment", ] @@ -32,7 +32,7 @@ def test_build_image_quiet_logger_filters(): config_service = MagicMock(spec=ConfigService) docker_service = DockerService(config_service) docker_service.build_image( - image_tag="itroom/python:latest", + image_tag="python:latest", dockerfile_path="config/Dockerfile.node20", build_dir="config", quiet=True, @@ -43,7 +43,7 @@ def test_build_image_quiet_logger_filters(): "Step 2/12 : RUN echo hi", "Step 3/12 : RUN echo ERROR inside step", "Successfully built deadbeef", - "Successfully tagged itroom/python:latest", + "Successfully tagged python:latest", "ERROR something failed", "SUCCESSFULLY starting deployment", ] @@ -84,7 +84,7 @@ def test_build_image_default_logger_prints_all(): config_service = MagicMock(spec=ConfigService) docker_service = DockerService(config_service) docker_service.build_image( - image_tag="itroom/python:latest", + image_tag="python:latest", dockerfile_path="config/Dockerfile.node20", build_dir="config", quiet=False, diff --git a/tests/docker_service/test_regressions.py b/tests/docker_service/test_regressions.py index 04e64e9..1323e43 100644 --- a/tests/docker_service/test_regressions.py +++ b/tests/docker_service/test_regressions.py @@ -76,7 +76,9 @@ def test_start_runners_running_and_restarted( base_image = cfg.runners_defaults.base_image m = __import__("re").search(r":([\d.]+)$", base_image) runner_version = m.group(1) if m else "latest" - expected_image = f"itroom/{cfg.runners[0].techno}:{cfg.runners[0].techno_version}-{runner_version}" + expected_image = ( + f"{cfg.runners[0].techno}:{cfg.runners[0].techno_version}-{runner_version}" + ) # Mocks docker_service.image_exists = MagicMock(return_value=True) diff --git a/tests/presentation/cli/test_webhook_commands_unit.py b/tests/presentation/cli/test_webhook_commands_unit.py index 57f9449..406a9e8 100644 --- a/tests/presentation/cli/test_webhook_commands_unit.py +++ b/tests/presentation/cli/test_webhook_commands_unit.py @@ -77,7 +77,7 @@ def load_config(): def fake_prompt_ask(msg, choices, default): called["asked"] = True - assert "événement" in msg + assert "Choose an event type" in msg return "runner_started" monkeypatch.setattr( @@ -159,7 +159,7 @@ def load_config(): printed = {} def fake_print(msg, *a, **k): - if "Envoi annulé" in str(msg): + if "Sending cancelled" in str(msg): printed["cancel"] = True console = types.SimpleNamespace(print=fake_print) @@ -197,9 +197,9 @@ def notify(self, event_type, data, provider=None): printed = {"success": 0, "fail": 0} def fake_print(msg, *a, **k): - if "Notification envoyée avec succès" in str(msg): + if "Notification sent successfully" in str(msg): printed["success"] += 1 - if "Échec de l'envoi" in str(msg): + if "Sending failed" in str(msg): printed["fail"] += 1 console = types.SimpleNamespace(print=fake_print) diff --git a/tests/services/conftest.py b/tests/services/conftest.py index 27493e2..299d432 100644 --- a/tests/services/conftest.py +++ b/tests/services/conftest.py @@ -1,6 +1,7 @@ -"""Configuration de tests spécifique au dossier services. +""" +Configuration for tests in the services folder. -Ce module fournit des fixtures et des hooks pour les tests dans le dossier services. +This module provides fixtures and hooks for tests in the services folder. """ import sys @@ -9,7 +10,7 @@ def pytest_configure(config): - """Ajout du marqueur isolated pour isoler certains tests sensibles aux effets de bord.""" + """Add the isolated marker to isolate certain tests from side effects.""" config.addinivalue_line( "markers", "isolated: mark test as isolated to avoid side effects" ) diff --git a/tests/services/test_notification_service.py b/tests/services/test_notification_service.py index b39a7f4..c868752 100644 --- a/tests/services/test_notification_service.py +++ b/tests/services/test_notification_service.py @@ -379,8 +379,7 @@ def test_remove_runners_deleted_and_not_available( ) # Vérifie affichage suppression indisponible assert any( - f"{expected_skipped_name} n'est pas disponible à la suppression" in s - for s in printed + f"{expected_skipped_name} is not available for removal" in s for s in printed ) diff --git a/tests/services/test_scheduler_service.py b/tests/services/test_scheduler_service.py index 0706f86..ff28ce8 100644 --- a/tests/services/test_scheduler_service.py +++ b/tests/services/test_scheduler_service.py @@ -11,38 +11,38 @@ class MockConsole: - """Mock pour la console Rich.""" + """Mock for Rich Console to capture printed messages.""" def __init__(self): self.messages = [] def print(self, message, *args, **kwargs): - """Enregistre les messages affichés.""" + """Capture printed messages.""" self.messages.append(message) return True class TestSchedulerService: - """Tests pour le service SchedulerService.""" + """Tests for SchedulerService.""" @pytest.fixture def mock_config_service(self): - """Fixture pour un ConfigService mockée.""" + """Fixture for a mocked ConfigService.""" return mock.MagicMock(spec=ConfigService) @pytest.fixture def mock_docker_service(self): - """Fixture pour un DockerService mockée.""" + """Fixture for a mocked DockerService.""" return mock.MagicMock(spec=DockerService) @pytest.fixture def mock_console(self): - """Fixture pour une console mockée.""" + """Fixture for a mocked console.""" return MockConsole() @pytest.fixture def scheduler_service(self, mock_config_service, mock_docker_service, mock_console): - """Fixture pour le service scheduler avec des dépendances mockées.""" + """Fixture for the scheduler service with mocked dependencies.""" return SchedulerService( config_service=mock_config_service, docker_service=mock_docker_service, @@ -51,14 +51,14 @@ def scheduler_service(self, mock_config_service, mock_docker_service, mock_conso @pytest.fixture def mock_schedule(self, monkeypatch): - """Fixture pour mocker le module schedule.""" + """Fixture for mocking the schedule module.""" mock_schedule = mock.MagicMock() monkeypatch.setattr("src.services.scheduler_service.schedule", mock_schedule) return mock_schedule @pytest.fixture def valid_config(self): - """Configuration valide pour les tests.""" + """Valid configuration for tests.""" mock_config = mock.MagicMock() mock_config.scheduler = mock.MagicMock( enabled=True, @@ -72,14 +72,14 @@ def valid_config(self): @pytest.fixture def disabled_config(self): - """Configuration avec scheduler désactivé.""" + """Configuration with scheduler disabled.""" mock_config = mock.MagicMock() mock_config.scheduler = mock.MagicMock(enabled=False) return mock_config @pytest.fixture def schedule_setup_service(self, scheduler_service): - """Service avec configuration de base pour les tests de setup_schedule.""" + """Service with basic configuration for setup_schedule tests.""" scheduler_service.interval_value = 1 scheduler_service.interval_unit = "s" scheduler_service.schedule_days = [] @@ -89,19 +89,19 @@ def schedule_setup_service(self, scheduler_service): @pytest.fixture def mock_datetime_patch(self): - """Patch pour datetime avec mock personnalisable.""" + """Patch for datetime with customizable mock.""" with mock.patch("src.services.scheduler_service.datetime") as mock_dt: yield mock_dt @pytest.fixture def configured_scheduler(self, scheduler_service): - """Scheduler configuré pour les tests d'exécution.""" + """Scheduler configured for execution tests.""" scheduler_service.check_time_window = mock.MagicMock(return_value=True) scheduler_service.actions = ["check"] return scheduler_service def test_init(self, scheduler_service): - """Test l'initialisation du service.""" + """Test initialization of the service.""" assert scheduler_service.config_service is not None assert scheduler_service.docker_service is not None assert scheduler_service.console is not None @@ -111,7 +111,7 @@ def test_init(self, scheduler_service): def test_load_config_enabled( self, scheduler_service, mock_config_service, valid_config ): - """Test le chargement d'une configuration avec scheduler activé.""" + """Test loading a configuration with scheduler enabled.""" mock_config_service.load_config.return_value = valid_config result = scheduler_service.load_config() @@ -123,24 +123,24 @@ def test_load_config_enabled( assert scheduler_service.max_retries == 5 def test_load_config_exception(self, scheduler_service, mock_config_service): - """Test le chargement d'une configuration avec une exception.""" + """Test loading a configuration with an exception.""" mock_config_service.load_config.side_effect = Exception("Test error") result = scheduler_service.load_config() assert result is False - assert "Erreur" in scheduler_service.console.messages[0] + assert "Error" in scheduler_service.console.messages[0] @pytest.mark.parametrize( "interval,time_window,days,expected", [ - ("invalid", "10:00-18:00", ["mon"], "Format d'intervalle invalide"), - ("30s", "invalid", ["mon"], "Format de plage horaire invalide"), - ("30s", "10:00-18:00", ["invalid"], "Aucun jour valide"), + ("invalid", "10:00-18:00", ["mon"], "Invalid interval format"), + ("30s", "invalid", ["mon"], "Invalid time window format"), + ("30s", "10:00-18:00", ["invalid"], "No valid day configured"), ], ) def test_validate_config_invalid( self, scheduler_service, interval, time_window, days, expected ): - """Test la validation avec différents paramètres invalides.""" + """Test validation with different invalid parameters.""" scheduler_service.check_interval = interval scheduler_service.time_window = time_window scheduler_service.allowed_days = days @@ -150,7 +150,7 @@ def test_validate_config_invalid( assert expected in scheduler_service.console.messages[0] def test_validate_config_valid(self, scheduler_service): - """Test la validation d'une configuration valide.""" + """Test validation of a valid configuration.""" scheduler_service.check_interval = "30s" scheduler_service.time_window = "10:00-18:00" scheduler_service.allowed_days = ["mon", "wed", "fri"] @@ -176,7 +176,7 @@ def test_validate_config_valid(self, scheduler_service): def test_check_time_window( self, scheduler_service, mock_datetime_patch, current_time, expected ): - """Test la vérification de la plage horaire.""" + """Test checking the time window.""" mock_now = mock.MagicMock() mock_now.time.return_value = current_time mock_datetime_patch.datetime.now.return_value = mock_now @@ -188,16 +188,20 @@ def test_check_time_window( assert result is expected def test_run_scheduled_tasks_outside_time_window(self, configured_scheduler): - """Test l'exécution des tâches en dehors de la plage horaire.""" + """Test running tasks outside the time window.""" configured_scheduler.check_time_window = mock.MagicMock(return_value=False) configured_scheduler.run_scheduled_tasks() - assert "Hors plage horaire" in configured_scheduler.console.messages[0] + assert "Outside allowed time window" in configured_scheduler.console.messages[0] @pytest.mark.parametrize( "docker_result,expected_retry,expected_msg", [ - ({"current_version": "1.0.0", "update_available": False}, 0, "déjà à jour"), - ({"error": "Test error"}, 1, "Erreur"), + ( + {"current_version": "1.0.0", "update_available": False}, + 0, + "is up to date", + ), + ({"error": "Test error"}, 1, "Error"), ], ) def test_run_scheduled_tasks_check_scenarios( @@ -208,7 +212,7 @@ def test_run_scheduled_tasks_check_scenarios( expected_retry, expected_msg, ): - """Test différents scénarios d'exécution de l'action check.""" + """Test different scenarios for the check action execution.""" mock_docker_service.check_base_image_update.return_value = docker_result configured_scheduler.run_scheduled_tasks() @@ -219,7 +223,7 @@ def test_run_scheduled_tasks_check_scenarios( def test_run_scheduled_tasks_check_update_available( self, configured_scheduler, mock_docker_service ): - """Test l'exécution de l'action check avec mise à jour disponible.""" + """Test execution of the check action with update available.""" mock_docker_service.check_base_image_update.return_value = { "current_version": "1.0.0", "latest_version": "2.0.0", @@ -228,12 +232,12 @@ def test_run_scheduled_tasks_check_update_available( configured_scheduler.run_scheduled_tasks() messages = configured_scheduler.console.messages - assert any("Nouvelle version disponible" in msg for msg in messages) + assert any("New version available" in msg for msg in messages) def test_run_scheduled_tasks_check_build( self, configured_scheduler, mock_docker_service ): - """Test l'exécution des actions check et build avec mise à jour.""" + """Test execution of the check and build actions with update.""" configured_scheduler.actions = ["check", "build"] mock_docker_service.check_base_image_update.return_value = { @@ -252,14 +256,14 @@ def test_run_scheduled_tasks_check_build( configured_scheduler.run_scheduled_tasks() messages = configured_scheduler.console.messages - assert any("base_image mise à jour" in msg for msg in messages) - assert any("Reconstruction des images" in msg for msg in messages) + assert any("base_image updated to" in msg for msg in messages) + assert any("Rebuilding runner image" in msg for msg in messages) assert any("SUCCESS" in msg for msg in messages) def test_run_scheduled_tasks_check_build_with_error( self, configured_scheduler, mock_docker_service ): - """Test l'exécution des actions check et build avec erreur de build.""" + """Test execution of the check and build actions with build error.""" configured_scheduler.actions = ["check", "build"] mock_docker_service.check_base_image_update.return_value = { @@ -279,12 +283,12 @@ def test_run_scheduled_tasks_check_build_with_error( assert configured_scheduler.retry_count == 1 messages = configured_scheduler.console.messages - assert any("ERREUR" in msg for msg in messages) + assert any("ERROR" in msg for msg in messages) def test_run_scheduled_tasks_check_build_deploy( self, configured_scheduler, mock_docker_service ): - """Test build + deploy automatique quand 'deploy' est présent et images construites.""" + """Test automatic build + deploy when 'deploy' is present and images are built.""" configured_scheduler.actions = ["check", "build", "deploy"] mock_docker_service.check_base_image_update.return_value = { @@ -309,14 +313,14 @@ def test_run_scheduled_tasks_check_build_deploy( configured_scheduler.run_scheduled_tasks() messages = configured_scheduler.console.messages - assert any("Déploiement automatique" in m for m in messages) - assert any("runner-1 démarré (deploy)" in m for m in messages) + assert any("Automatic deployment" in m for m in messages) + assert any("runner-1 started (deploy)" in m for m in messages) mock_docker_service.start_runners.assert_called_once() def test_run_scheduled_tasks_check_build_deploy_no_built( self, configured_scheduler, mock_docker_service ): - """Test que deploy n'est pas appelé si rien n'a été built.""" + """Test that deploy is not called if nothing was built.""" configured_scheduler.actions = ["check", "build", "deploy"] mock_docker_service.check_base_image_update.return_value = { @@ -333,14 +337,13 @@ def test_run_scheduled_tasks_check_build_deploy_no_built( } configured_scheduler.run_scheduled_tasks() messages = configured_scheduler.console.messages - # Pas de message de déploiement assert not any("Déploiement automatique" in m for m in messages) mock_docker_service.start_runners.assert_not_called() def test_run_scheduled_tasks_check_build_deploy_all_states( self, configured_scheduler, mock_docker_service ): - """Test deploy automatique couvrant restarted, running, removed, errors.""" + """Test automatic deploy covering restarted, running, removed, errors.""" configured_scheduler.actions = ["check", "build", "deploy"] mock_docker_service.check_base_image_update.return_value = { @@ -365,17 +368,16 @@ def test_run_scheduled_tasks_check_build_deploy_all_states( configured_scheduler.run_scheduled_tasks() messages = configured_scheduler.console.messages - # Vérifie chaque message spécifique - assert any("runner-r1 redémarré (deploy)" in m for m in messages) - assert any("runner-r2 déjà en cours (deploy)" in m for m in messages) - assert any("old-container supprimé (deploy)" in m for m in messages) + assert any("runner-r1 restarted" in m for m in messages) + assert any("runner-r2 already running (deploy)" in m for m in messages) + assert any("old-container removed (deploy)" in m for m in messages) assert any("bad: failure (deploy)" in m for m in messages) mock_docker_service.start_runners.assert_called_once() def test_run_scheduled_tasks_check_build_deploy_exception( self, configured_scheduler, mock_docker_service ): - """Test exception lors du déploiement automatique (start_runners).""" + """Test exception during automatic deployment (start_runners).""" configured_scheduler.actions = ["check", "build", "deploy"] configured_scheduler.retry_count = 0 @@ -395,13 +397,13 @@ def test_run_scheduled_tasks_check_build_deploy_exception( configured_scheduler.run_scheduled_tasks() messages = configured_scheduler.console.messages - assert any("Erreur lors du déploiement automatique" in m for m in messages) + assert any("Error during automatic deployment" in m for m in messages) assert configured_scheduler.retry_count == 1 def test_run_scheduled_tasks_max_retries( self, configured_scheduler, mock_docker_service ): - """Test l'atteinte du nombre maximal de tentatives.""" + """Test reaching the maximum number of retries.""" configured_scheduler.max_retries = 1 configured_scheduler.retry_count = 1 configured_scheduler.stop = mock.MagicMock() @@ -412,13 +414,13 @@ def test_run_scheduled_tasks_max_retries( configured_scheduler.run_scheduled_tasks() messages = configured_scheduler.console.messages - assert any("Nombre maximal de tentatives atteint" in msg for msg in messages) + assert any("Maximum retry count reached" in msg for msg in messages) configured_scheduler.stop.assert_called_once() def test_run_scheduled_tasks_exception( self, configured_scheduler, mock_docker_service ): - """Test une exception pendant l'exécution des tâches.""" + """Test an exception during task execution.""" configured_scheduler.max_retries = 2 configured_scheduler.retry_count = 0 configured_scheduler.stop = mock.MagicMock() @@ -430,7 +432,7 @@ def test_run_scheduled_tasks_exception( assert configured_scheduler.retry_count == 1 messages = configured_scheduler.console.messages - assert any("Erreur pendant l'exécution des tâches" in msg for msg in messages) + assert any("Error occurred while executing tasks" in msg for msg in messages) # Simuler une nouvelle exécution pour atteindre max_retries configured_scheduler.retry_count = 2 @@ -448,13 +450,12 @@ def test_run_scheduled_tasks_exception( def test_setup_schedule_intervals( self, schedule_setup_service, mock_schedule, interval_unit, mock_attr ): - """Test la configuration du scheduler avec différents intervalles.""" + """Test configuration of the scheduler with different intervals.""" schedule_setup_service.interval_value = ( 30 if interval_unit == "s" else 5 if interval_unit == "m" else 1 ) schedule_setup_service.interval_unit = interval_unit - # Configure le mock mock_job = mock.MagicMock() mock_time_unit = mock.MagicMock() mock_time_unit.do.return_value = mock_job @@ -464,14 +465,13 @@ def test_setup_schedule_intervals( schedule_setup_service._setup_schedule() - # Vérifications mock_schedule.clear.assert_called_once() expected_value = schedule_setup_service.interval_value mock_schedule.every.assert_any_call(expected_value) mock_time_unit.do.assert_called_with(schedule_setup_service.run_scheduled_tasks) def test_setup_schedule_invalid_day(self, schedule_setup_service, mock_schedule): - """Test la configuration avec un jour invalide.""" + """Test configuration with an invalid day.""" schedule_setup_service.schedule_days = ["invalid_day"] mock_seconds_job = mock.MagicMock() mock_seconds = mock.MagicMock() @@ -488,7 +488,7 @@ def test_setup_schedule_invalid_day(self, schedule_setup_service, mock_schedule) def test_setup_schedule_invalid_interval_unit( self, schedule_setup_service, mock_schedule ): - """Test la configuration avec une unité d'intervalle invalide.""" + """Test configuration with an invalid interval unit.""" schedule_setup_service.interval_unit = "x" schedule_setup_service._setup_schedule() assert len(schedule_setup_service._jobs) == 0 @@ -498,17 +498,17 @@ def test_setup_schedule_invalid_interval_unit( ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"], ) def test_setup_schedule_days(self, schedule_setup_service, mock_schedule, day_attr): - """Test la configuration du scheduler pour tous les jours de la semaine.""" + """Test configuration of the scheduler for all days of the week.""" schedule_setup_service.schedule_days = [day_attr] - # Configure les mocks pour l'intervalle de secondes + # Configure mocks for seconds interval mock_seconds = mock.MagicMock() mock_seconds_job = mock.MagicMock() mock_seconds.do.return_value = mock_seconds_job mock_every_result = mock.MagicMock() mock_every_result.seconds = mock_seconds - # Configure les mocks pour le jour spécifique + # Configure mocks for specific day mock_day_job = mock.MagicMock() mock_day_at_do = mock.MagicMock() mock_day_at_do.do.return_value = mock_day_job @@ -517,21 +517,20 @@ def test_setup_schedule_days(self, schedule_setup_service, mock_schedule, day_at mock_day = mock.MagicMock() mock_day.at = mock_day_at - # Configure les retours de schedule.every() + # Configure mocks for schedule.every() returns mock_every_day = mock.MagicMock() setattr(mock_every_day, day_attr, mock_day) mock_schedule.every.side_effect = [mock_every_result, mock_every_day] schedule_setup_service._setup_schedule() - # Vérifications mock_day.at.assert_called_with("10:00") mock_day_at_do.do.assert_called_with(schedule_setup_service.run_scheduled_tasks) assert len(schedule_setup_service._jobs) == 2 assert mock_day_job in schedule_setup_service._jobs def test_start_config_failed(self, scheduler_service): - """Test le démarrage du scheduler quand la configuration échoue.""" + """Test starting the scheduler when configuration fails.""" scheduler_service.load_config = mock.MagicMock(return_value=False) scheduler_service._setup_schedule = mock.MagicMock() scheduler_service.start() @@ -541,7 +540,7 @@ def test_start_config_failed(self, scheduler_service): @mock.patch("src.services.scheduler_service.time") def test_start_and_stop(self, mock_time, scheduler_service): - """Test le démarrage et l'arrêt du scheduler.""" + """Test starting and stopping the scheduler.""" scheduler_service.load_config = mock.MagicMock(return_value=True) scheduler_service._setup_schedule = mock.MagicMock() scheduler_service.check_interval = "30s" @@ -557,20 +556,20 @@ def side_effect(*args, **kwargs): scheduler_service.load_config.assert_called_once() scheduler_service._setup_schedule.assert_called_once() - assert "Scheduler démarré" in scheduler_service.console.messages[0] + assert "Scheduler started" in scheduler_service.console.messages[0] assert not scheduler_service.is_running @pytest.mark.parametrize( "exception_type,expected_msg", [ - (KeyboardInterrupt(), "Scheduler arrêté manuellement."), - (Exception("fail"), "Erreur dans le scheduler: fail"), + (KeyboardInterrupt(), "Scheduler stopped manually."), + (Exception("fail"), "Error in scheduler: fail"), ], ) def test_start_exceptions( self, scheduler_service, mock_schedule, exception_type, expected_msg ): - """Test start avec différentes exceptions dans la boucle principale.""" + """Test starting with different exceptions in the main loop.""" scheduler_service.load_config = mock.MagicMock(return_value=True) scheduler_service._setup_schedule = mock.MagicMock() scheduler_service.is_running = True @@ -589,7 +588,7 @@ def test_start_exceptions( assert not scheduler_service.is_running def test_stop(self, scheduler_service, mock_schedule): - """Test l'arrêt du scheduler.""" + """Test stopping the scheduler.""" scheduler_service.is_running = True scheduler_service._jobs = ["job1", "job2"] @@ -600,7 +599,7 @@ def test_stop(self, scheduler_service, mock_schedule): mock_schedule.clear.assert_called_once() def test_execute_actions_no_check(self, scheduler_service): - """Test _execute_actions quand 'check' n'est pas dans actions.""" + """Test _execute_actions when 'check' is not in actions.""" scheduler_service.actions = ["build"] scheduler_service._execute_actions() # Ne doit pas lever d'exception @@ -622,12 +621,12 @@ def test_execute_actions_no_check(self, scheduler_service): def test_execute_check_action_edge_cases( self, scheduler_service, mock_docker_service, docker_result, expected_behavior ): - """Test _execute_check_action pour les cas limites.""" + """Test _execute_check_action with edge case results.""" scheduler_service.actions = ["check"] mock_docker_service.check_base_image_update.return_value = docker_result scheduler_service.docker_service = mock_docker_service - scheduler_service._execute_check_action() # Ne doit pas lever d'exception + scheduler_service._execute_check_action() # Should not raise an exception @pytest.mark.parametrize( "build_result,expected_retry", @@ -654,7 +653,7 @@ def test_execute_check_action_edge_cases( def test_execute_build_action_scenarios( self, scheduler_service, mock_docker_service, build_result, expected_retry ): - """Test _execute_build_action avec différents résultats de build.""" + """Test _execute_build_action with different build results.""" scheduler_service.docker_service = mock_docker_service mock_docker_service.build_runner_images.return_value = build_result initial_retry = scheduler_service.retry_count diff --git a/tests/services/test_webhook_service.py b/tests/services/test_webhook_service.py index 2f79da4..3dd37cb 100644 --- a/tests/services/test_webhook_service.py +++ b/tests/services/test_webhook_service.py @@ -414,7 +414,7 @@ def test_notify_unknown_provider_prints_message(): ) 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) + assert any("not configured" in str(m) for m in messages) def test_format_slack_payload_defaults_and_channel(service): @@ -493,7 +493,7 @@ def test_notify_failure_prints_error(monkeypatch): 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) + assert any("Failed to send notification" in str(m) for m in messages) # Removed duplicate, unindented function definition