From 4c08606d0cf2e40f8e97d93b4629a3addc4a9249 Mon Sep 17 00:00:00 2001 From: Emil Simeonov Date: Thu, 18 Jun 2026 23:58:43 +0200 Subject: [PATCH] GRM-9: feat: add runner registry for simplified CLI UX --- README.md | 50 +++-- src/gitea_runner_manager/cli.py | 182 ++++++++-------- src/gitea_runner_manager/executor.py | 34 +++ src/gitea_runner_manager/i18n.py | 101 +++++++++ src/gitea_runner_manager/registry.py | 83 ++++++++ src/gitea_runner_manager/runner_manager.py | 180 +++++++++++----- tests/unit/test_cli.py | 123 ++++++++--- tests/unit/test_executor.py | 42 ++++ tests/unit/test_registry.py | 85 ++++++++ tests/unit/test_runner_manager.py | 233 ++++++++++++++++----- 10 files changed, 869 insertions(+), 244 deletions(-) create mode 100644 src/gitea_runner_manager/registry.py create mode 100644 tests/unit/test_registry.py diff --git a/README.md b/README.md index d17851d..8af8774 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ By default, runners are deployed as **Docker containers** using the official `gi - **Secure** — no hardcoded secrets, uses scoped tokens. - **Idempotent** — can be run multiple times safely. - **Flexible** — accepts a plain IP address or hostname, and allows specifying the SSH user and private key. +- **Runner registry** — stores runner connection metadata locally after installation. Subsequent commands need only the runner name. - **Lifecycle management** — start, stop, enable, disable, status, and remove runners via CLI. - **Multi-instance** — run multiple isolated runners on the same host, each with its own data directory and systemd service. - **Systemd-managed** — both Docker and binary modes run under systemd template units (`gitea-runner@.service`). @@ -83,28 +84,43 @@ make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runne make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner MODE=binary ASK_BECOME_PASS=1 ``` -### Manage Runner Lifecycle +### Runner Registry -GRM provides commands to manage runners after installation. All commands below prompt for the sudo password interactively: +After installation, GRM stores each runner's connection details (host, user, SSH key, mode, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments: ```bash -# Start a runner (registers if .runner file is missing) -grm start 192.168.1.10 --user ubuntu --name prod-runner --ask-become-pass +# List all registered runners with live systemd status +grm list +``` -# Stop a runner (graceful stop, no deregistration) -grm stop 192.168.1.10 --user ubuntu --name prod-runner --ask-become-pass +### Manage Runner Lifecycle + +Once a runner is installed, lifecycle commands work by runner name only: + +```bash +# Start a runner +grm start prod-runner + +# Stop a runner +grm stop prod-runner # Enable a runner to start on boot -grm enable 192.168.1.10 --user ubuntu --name prod-runner --ask-become-pass +grm enable prod-runner -# Disable a runner (stops, deregisters from Gitea, and disables systemd) -grm disable 192.168.1.10 --user ubuntu --name prod-runner --token --ask-become-pass +# Disable a runner (stops, deregisters, and disables systemd) +grm disable prod-runner --token # Check runner status -grm status 192.168.1.10 --user ubuntu --name prod-runner --ask-become-pass +grm status prod-runner -# Remove a runner completely (stops, disables, deregisters, removes files) -grm remove 192.168.1.10 --user ubuntu --name prod-runner --token --ask-become-pass +# Remove a runner completely +grm remove prod-runner --token +``` + +You can override any stored value by passing the corresponding flag: + +```bash +grm start prod-runner --host 192.168.1.11 --user root --mode binary ``` > **Automation tip:** If the remote host has passwordless sudo configured, omit `--ask-become-pass`. @@ -118,9 +134,9 @@ Each runner instance is fully isolated with its own data directory and systemd s grm install 192.168.1.10 --user ubuntu --name workflow-runner --ask-become-pass grm install 192.168.1.10 --user ubuntu --name build-runner --mode binary --ask-become-pass -# Manage them independently -grm stop 192.168.1.10 --user ubuntu --name workflow-runner --ask-become-pass -grm status 192.168.1.10 --user ubuntu --name build-runner --ask-become-pass +# Manage them independently by name +grm stop workflow-runner +grm status build-runner ``` ### Verify Runner @@ -207,8 +223,9 @@ Override any variable by passing it to the CLI with `--extra-vars` or by setting . ├── src/gitea_runner_manager/ # Python CLI source │ ├── cli.py # Click commands -│ ├── runner_manager.py # Ansible orchestration +│ ├── runner_manager.py # Ansible orchestration + registry integration │ ├── executor.py # Ansible subprocess execution +│ ├── registry.py # Local JSON runner registry │ ├── i18n.py # Translations (en, bg, de, ru, zh) │ └── exceptions.py # Custom exceptions ├── ansible/ @@ -337,6 +354,7 @@ The test checks two things: | `disable` | Disables and deregisters a runner | | `status` | Checks runner status | | `remove` | Removes a runner completely | +| `list` | Lists registered runners with live status | | `lint` | Runs Python linters | | `ansible-lint` | Runs `ansible-lint` | | `test-unit` | Runs unit tests with coverage | diff --git a/src/gitea_runner_manager/cli.py b/src/gitea_runner_manager/cli.py index 8da30a0..b60daff 100644 --- a/src/gitea_runner_manager/cli.py +++ b/src/gitea_runner_manager/cli.py @@ -129,36 +129,33 @@ def update( raise click.ClickException(_("Update failed: {error}", error=e)) from e -@cli.command(help=_("Start and configure a Gitea Runner on a remote host.")) -@click.argument("host") -@click.option( - "--user", - "-u", - default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()), - help=_("SSH user"), -) -@click.option("--name", "-n", help=_("Gitea Runner name (default: host)")) +@cli.command(help=_("Start a registered Gitea Runner.")) +@click.argument("runner_name") +@click.option("--host", help=_("Override host from registry")) +@click.option("--user", "-u", help=_("Override user from registry")) +@click.option("--key", "-k", help=_("Override SSH key from registry")) @click.option( "--mode", "-m", type=click.Choice(["docker", "binary"]), - default="docker", - help=_("Gitea Runner deployment mode (default: docker)"), + help=_("Override deployment mode from registry"), ) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) def start( - host: str, - user: str, - name: str | None, - mode: str, + runner_name: str, + host: str | None, + user: str | None, + key: str | None, + mode: str | None, ask_become_pass: bool, ) -> None: manager = RunnerManager() try: manager.start( + name=runner_name, host=host, user=user, - name=name, + key=key, mode=mode, ask_become_pass=ask_become_pass, ) @@ -166,71 +163,63 @@ def start( raise click.ClickException(_("Start failed: {error}", error=e)) from e -@cli.command(help=_("Stop a Gitea Runner on a remote host.")) -@click.argument("host") -@click.option( - "--user", - "-u", - default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()), - help=_("SSH user"), -) -@click.option("--name", "-n", help=_("Gitea Runner name (default: host)")) +@cli.command(help=_("Stop a registered Gitea Runner.")) +@click.argument("runner_name") +@click.option("--host", help=_("Override host from registry")) +@click.option("--user", "-u", help=_("Override user from registry")) +@click.option("--key", "-k", help=_("Override SSH key from registry")) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) def stop( - host: str, - user: str, - name: str | None, + runner_name: str, + host: str | None, + user: str | None, + key: str | None, ask_become_pass: bool, ) -> None: manager = RunnerManager() try: manager.stop( + name=runner_name, host=host, user=user, - name=name, + key=key, ask_become_pass=ask_become_pass, ) except GRMError as e: raise click.ClickException(_("Stop failed: {error}", error=e)) from e -@cli.command(help=_("Enable a Gitea Runner to start on boot.")) -@click.argument("host") -@click.option( - "--user", - "-u", - default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()), - help=_("SSH user"), -) -@click.option("--name", "-n", help=_("Gitea Runner name (default: host)")) +@cli.command(help=_("Enable a registered Gitea Runner to start on boot.")) +@click.argument("runner_name") +@click.option("--host", help=_("Override host from registry")) +@click.option("--user", "-u", help=_("Override user from registry")) +@click.option("--key", "-k", help=_("Override SSH key from registry")) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) def enable( - host: str, - user: str, - name: str | None, + runner_name: str, + host: str | None, + user: str | None, + key: str | None, ask_become_pass: bool, ) -> None: manager = RunnerManager() try: manager.enable( + name=runner_name, host=host, user=user, - name=name, + key=key, ask_become_pass=ask_become_pass, ) except GRMError as e: raise click.ClickException(_("Enable failed: {error}", error=e)) from e -@cli.command(help=_("Disable a Gitea Runner and deregister it.")) -@click.argument("host") -@click.option( - "--user", - "-u", - default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()), - help=_("SSH user"), -) -@click.option("--name", "-n", help=_("Gitea Runner name (default: host)")) +@cli.command(help=_("Disable a registered Gitea Runner and deregister it.")) +@click.argument("runner_name") +@click.option("--host", help=_("Override host from registry")) +@click.option("--user", "-u", help=_("Override user from registry")) +@click.option("--key", "-k", help=_("Override SSH key from registry")) @click.option( "--token", "-t", @@ -241,16 +230,16 @@ def enable( "--mode", "-m", type=click.Choice(["docker", "binary"]), - default="docker", - help=_("Gitea Runner deployment mode (default: docker)"), + help=_("Override deployment mode from registry"), ) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) def disable( - host: str, - user: str, - name: str | None, + runner_name: str, + host: str | None, + user: str | None, + key: str | None, token: str | None, - mode: str, + mode: str | None, ask_become_pass: bool, ) -> None: gitea_url = os.getenv("GITEA_URL", "") @@ -259,9 +248,10 @@ def disable( manager = RunnerManager() try: manager.disable( + name=runner_name, host=host, user=user, - name=name, + key=key, token=token, gitea_url=gitea_url, mode=mode, @@ -271,36 +261,33 @@ def disable( raise click.ClickException(_("Disable failed: {error}", error=e)) from e -@cli.command(help=_("Check the status of a Gitea Runner.")) -@click.argument("host") -@click.option( - "--user", - "-u", - default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()), - help=_("SSH user"), -) -@click.option("--name", "-n", help=_("Gitea Runner name (default: host)")) +@cli.command(help=_("Check the status of a registered Gitea Runner.")) +@click.argument("runner_name") +@click.option("--host", help=_("Override host from registry")) +@click.option("--user", "-u", help=_("Override user from registry")) +@click.option("--key", "-k", help=_("Override SSH key from registry")) @click.option( "--mode", "-m", type=click.Choice(["docker", "binary"]), - default="docker", - help=_("Gitea Runner deployment mode (default: docker)"), + help=_("Override deployment mode from registry"), ) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) def status( - host: str, - user: str, - name: str | None, - mode: str, + runner_name: str, + host: str | None, + user: str | None, + key: str | None, + mode: str | None, ask_become_pass: bool, ) -> None: manager = RunnerManager() try: manager.status( + name=runner_name, host=host, user=user, - name=name, + key=key, mode=mode, ask_become_pass=ask_become_pass, ) @@ -308,15 +295,11 @@ def status( raise click.ClickException(_("Status check failed: {error}", error=e)) from e -@cli.command(help=_("Remove a Gitea Runner completely.")) -@click.argument("host") -@click.option( - "--user", - "-u", - default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()), - help=_("SSH user"), -) -@click.option("--name", "-n", help=_("Gitea Runner name (default: host)")) +@cli.command(help=_("Remove a registered Gitea Runner completely.")) +@click.argument("runner_name") +@click.option("--host", help=_("Override host from registry")) +@click.option("--user", "-u", help=_("Override user from registry")) +@click.option("--key", "-k", help=_("Override SSH key from registry")) @click.option( "--token", "-t", @@ -327,16 +310,16 @@ def status( "--mode", "-m", type=click.Choice(["docker", "binary"]), - default="docker", - help=_("Gitea Runner deployment mode (default: docker)"), + help=_("Override deployment mode from registry"), ) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) def remove( - host: str, - user: str, - name: str | None, + runner_name: str, + host: str | None, + user: str | None, + key: str | None, token: str | None, - mode: str, + mode: str | None, ask_become_pass: bool, ) -> None: gitea_url = os.getenv("GITEA_URL", "") @@ -345,9 +328,10 @@ def remove( manager = RunnerManager() try: manager.remove( + name=runner_name, host=host, user=user, - name=name, + key=key, token=token, gitea_url=gitea_url, mode=mode, @@ -355,3 +339,21 @@ def remove( ) except GRMError as e: raise click.ClickException(_("Remove failed: {error}", error=e)) from e + + +@cli.command(name="list", help=_("List all registered runners with live status.")) +def list_runners() -> None: + manager = RunnerManager() + try: + runners = manager.list_runners() + except GRMError as e: + raise click.ClickException(_("List failed: {error}", error=e)) from e + + if not runners: + click.echo(_("No runners registered. Use 'grm install' to add one.")) + return + + click.echo(f"{'NAME':<20} {'HOST':<18} {'USER':<12} {'MODE':<8} {'STATUS'}") + click.echo("-" * 70) + for r in runners: + click.echo(f"{r['name']:<20} {r['host']:<18} {r['user']:<12} {r['mode']:<8} {r['status']}") diff --git a/src/gitea_runner_manager/executor.py b/src/gitea_runner_manager/executor.py index 3d59d71..c2791f7 100644 --- a/src/gitea_runner_manager/executor.py +++ b/src/gitea_runner_manager/executor.py @@ -73,6 +73,40 @@ class AnsibleExecutor: proc.wait() return proc.returncode + def run_ad_hoc( + self, + host: str, + user: str, + key: str | None, + module: str, + args: str, + become: bool = False, + ) -> str: + """Run an Ansible ad-hoc command and return stdout.""" + cmd = [ + "ansible", + host, + "-m", + module, + "-a", + args, + "-u", + user, + ] + if key: + cmd.extend(["--private-key", key]) + if become: + cmd.append("--become") + + env = os.environ.copy() + proc = subprocess.run( + cmd, + env=env, + capture_output=True, + text=True, + ) + return proc.stdout.strip() + def _extract_status(self, log_file: Path) -> str | None: """Extract the runner status message from the Ansible log.""" try: diff --git a/src/gitea_runner_manager/i18n.py b/src/gitea_runner_manager/i18n.py index a143a59..e4c2aaa 100644 --- a/src/gitea_runner_manager/i18n.py +++ b/src/gitea_runner_manager/i18n.py @@ -303,6 +303,107 @@ TRANSLATIONS: dict[str, dict[str, str]] = { "ru": "Команда: {cmd}", "zh": "命令: {cmd}", }, + "Start a registered Gitea Runner.": { + "en": "Start a registered Gitea Runner.", + "bg": "Стартиране на регистриран Gitea Runner.", + "de": "Einen registrierten Gitea Runner starten.", + "ru": "Запустить зарегистрированный Gitea Runner.", + "zh": "启动已注册的 Gitea Runner。", + }, + "Stop a registered Gitea Runner.": { + "en": "Stop a registered Gitea Runner.", + "bg": "Спиране на регистриран Gitea Runner.", + "de": "Einen registrierten Gitea Runner stoppen.", + "ru": "Остановить зарегистрированный Gitea Runner.", + "zh": "停止已注册的 Gitea Runner。", + }, + "Enable a registered Gitea Runner to start on boot.": { + "en": "Enable a registered Gitea Runner to start on boot.", + "bg": "Разрешаване на регистриран Gitea Runner да стартира при зареждане.", + "de": "Einen registrierten Gitea Runner für den Autostart aktivieren.", + "ru": "Включить автозапуск зарегистрированного Gitea Runner.", + "zh": "启用已注册的 Gitea Runner 开机自启。", + }, + "Disable a registered Gitea Runner and deregister it.": { + "en": "Disable a registered Gitea Runner and deregister it.", + "bg": "Деактивиране на регистриран Gitea Runner и дерегистриране.", + "de": "Einen registrierten Gitea Runner deaktivieren und abmelden.", + "ru": "Отключить и дерегистрировать зарегистрированный Gitea Runner.", + "zh": "禁用并注销已注册的 Gitea Runner。", + }, + "Check the status of a registered Gitea Runner.": { + "en": "Check the status of a registered Gitea Runner.", + "bg": "Проверка на състоянието на регистриран Gitea Runner.", + "de": "Status eines registrierten Gitea Runners prüfen.", + "ru": "Проверить состояние зарегистрированного Gitea Runner.", + "zh": "检查已注册的 Gitea Runner 状态。", + }, + "Remove a registered Gitea Runner completely.": { + "en": "Remove a registered Gitea Runner completely.", + "bg": "Пълно премахване на регистриран Gitea Runner.", + "de": "Einen registrierten Gitea Runner vollständig entfernen.", + "ru": "Полностью удалить зарегистрированный Gitea Runner.", + "zh": "完全移除已注册的 Gitea Runner。", + }, + "Override host from registry": { + "en": "Override host from registry", + "bg": "Замяна на хоста от регистъра", + "de": "Host aus Registrierung überschreiben", + "ru": "Переопределить хост из реестра", + "zh": "覆盖注册表中的主机", + }, + "Override user from registry": { + "en": "Override user from registry", + "bg": "Замяна на потребителя от регистъра", + "de": "Benutzer aus Registrierung überschreiben", + "ru": "Переопределить пользователя из реестра", + "zh": "覆盖注册表中的用户", + }, + "Override SSH key from registry": { + "en": "Override SSH key from registry", + "bg": "Замяна на SSH ключа от регистъра", + "de": "SSH-Schlüssel aus Registrierung überschreiben", + "ru": "Переопределить SSH ключ из реестра", + "zh": "覆盖注册表中的 SSH 密钥", + }, + "Override deployment mode from registry": { + "en": "Override deployment mode from registry", + "bg": "Замяна на режима на внедряване от регистъра", + "de": "Bereitstellungsmodus aus Registrierung überschreiben", + "ru": "Переопределить режим развертывания из реестра", + "zh": "覆盖注册表中的部署模式", + }, + "Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.": { + "en": "Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.", + "bg": "Runner '{name}' не е намерен в регистъра. Използвайте 'grm install' първо или подайте --host и --user.", + "de": ( + "Runner '{name}' nicht in Registrierung gefunden. " + "Verwenden Sie zuerst 'grm install' oder geben Sie --host und --user an." + ), + "ru": "Runner '{name}' не найден в реестре. Сначала используйте 'grm install' или укажите --host и --user.", + "zh": "注册表中未找到 Runner '{name}'。请先使用 'grm install' 或提供 --host 和 --user。", + }, + "List all registered runners with live status.": { + "en": "List all registered runners with live status.", + "bg": "Списък на всички регистрирани runners с актуално състояние.", + "de": "Alle registrierten Runner mit Live-Status auflisten.", + "ru": "Список всех зарегистрированных runners с текущим статусом.", + "zh": "列出所有已注册 runners 的实时状态。", + }, + "No runners registered. Use 'grm install' to add one.": { + "en": "No runners registered. Use 'grm install' to add one.", + "bg": "Няма регистрирани runners. Използвайте 'grm install', за да добавите.", + "de": "Keine Runner registriert. Verwenden Sie 'grm install', um einen hinzuzufügen.", + "ru": "Нет зарегистрированных runners. Используйте 'grm install' чтобы добавить.", + "zh": "没有已注册的 runners。使用 'grm install' 添加一个。", + }, + "List failed: {error}": { + "en": "List failed: {error}", + "bg": "Списъкът неуспешен: {error}", + "de": "Auflistung fehlgeschlagen: {error}", + "ru": "Ошибка списка: {error}", + "zh": "列表失败: {error}", + }, } diff --git a/src/gitea_runner_manager/registry.py b/src/gitea_runner_manager/registry.py new file mode 100644 index 0000000..c465ecb --- /dev/null +++ b/src/gitea_runner_manager/registry.py @@ -0,0 +1,83 @@ +"""Local JSON registry for installed Gitea Runners. + +Stores connection metadata (host, user, key, mode) on the local machine +so that subsequent lifecycle commands only need the runner name. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, cast + +from .exceptions import GRMError +from .i18n import _ + + +class RunnerRegistry: + """Manages a local JSON file mapping runner names to connection metadata.""" + + def __init__(self, path: Path | None = None) -> None: + self._path = path or Path.home() / ".local" / "share" / "grm" / "runners.json" + self._data: dict[str, dict[str, Any]] = self._load() + + def _load(self) -> dict[str, dict[str, Any]]: + if self._path.exists(): + try: + with open(self._path) as f: + data: Any = json.load(f) + if isinstance(data, dict): + return cast(dict[str, dict[str, Any]], data) + except (json.JSONDecodeError, OSError): + pass + return {} + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + with open(self._path, "w") as f: + json.dump(self._data, f, indent=2) + + def add( + self, + name: str, + host: str, + user: str, + key: str | None = None, + mode: str = "docker", + gitea_url: str = "", + ) -> None: + """Register a runner in the local database.""" + self._data[name] = { + "host": host, + "user": user, + "key": key, + "mode": mode, + "gitea_url": gitea_url, + "created_at": datetime.now(UTC).isoformat(), + } + self._save() + + def get(self, name: str) -> dict[str, Any] | None: + """Retrieve runner metadata by name.""" + info = self._data.get(name) + if info: + return dict(info) + return None + + def remove(self, name: str) -> None: + """Remove a runner from the local database.""" + if name in self._data: + del self._data[name] + self._save() + + def list(self) -> dict[str, dict[str, Any]]: + """Return a copy of all registered runners.""" + return {name: dict(info) for name, info in self._data.items()} + + def update(self, name: str, **kwargs: str | None) -> None: + """Update fields for an existing runner entry.""" + if name not in self._data: + raise GRMError(_("Runner '{name}' not found in registry.", name=name)) + self._data[name].update({k: v for k, v in kwargs.items() if v is not None}) + self._save() diff --git a/src/gitea_runner_manager/runner_manager.py b/src/gitea_runner_manager/runner_manager.py index 0266c5a..dca4fdc 100644 --- a/src/gitea_runner_manager/runner_manager.py +++ b/src/gitea_runner_manager/runner_manager.py @@ -7,13 +7,19 @@ from pathlib import Path from .exceptions import AnsibleError from .executor import AnsibleExecutor from .i18n import _ +from .registry import RunnerRegistry class RunnerManager: - """Orchestrates runner installation and updates.""" + """Orchestrates runner installation, updates, and lifecycle.""" - def __init__(self, executor: AnsibleExecutor | None = None) -> None: + def __init__( + self, + executor: AnsibleExecutor | None = None, + registry: RunnerRegistry | None = None, + ) -> None: self._executor = executor or AnsibleExecutor() + self._registry = registry or RunnerRegistry() def install( self, @@ -45,6 +51,15 @@ class RunnerManager: cmd = self._build_cmd("install-runner.yml", host, user, extra_vars, key, ask_become_pass) self._executor.run(cmd, description=_("Installing Gitea Runner on {host}", host=host)) + self._registry.add( + name=name, + host=host, + user=user, + key=key, + mode=mode, + gitea_url=gitea_url, + ) + def update( self, host: str, @@ -62,101 +77,162 @@ class RunnerManager: cmd = self._build_cmd("update-runner.yml", host, user, extra_vars, key, ask_become_pass) self._executor.run(cmd, description=_("Updating Gitea Runner on {host}", host=host)) + def _resolve_runner( + self, + name: str, + host: str | None = None, + user: str | None = None, + key: str | None = None, + mode: str | None = None, + ) -> tuple[str, str, str | None, str]: + """Look up runner metadata from registry, applying CLI overrides.""" + info = self._registry.get(name) + if host and user: + # Explicit connection details — bypass registry + actual_host = host + actual_user = user + actual_key = key + actual_mode = mode or "docker" + elif info: + actual_host = host or info["host"] + actual_user = user or info["user"] + actual_key = key if key is not None else info.get("key") + actual_mode = mode or info.get("mode", "docker") + else: + raise AnsibleError( + _( + "Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.", + name=name, + ) + ) + return actual_host, actual_user, actual_key, actual_mode + def start( self, - host: str, - user: str, - name: str | None = None, - mode: str = "docker", + name: str, + host: str | None = None, + user: str | None = None, + key: str | None = None, + mode: str | None = None, ask_become_pass: bool = False, ) -> None: """Start a runner instance on a remote host.""" - if not name: - name = host - extra_vars = f"runner_name={name} runner_mode={mode}" - cmd = self._build_cmd("start-runner.yml", host, user, extra_vars, None, ask_become_pass) - self._executor.run(cmd, description=_("Starting Gitea Runner {name} on {host}", name=name, host=host)) + actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode) + extra_vars = f"runner_name={name} runner_mode={actual_mode}" + cmd = self._build_cmd("start-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass) + self._executor.run(cmd, description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host)) def stop( self, - host: str, - user: str, - name: str | None = None, + name: str, + host: str | None = None, + user: str | None = None, + key: str | None = None, ask_become_pass: bool = False, ) -> None: """Stop a runner instance on a remote host.""" - if not name: - name = host + actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key) extra_vars = f"runner_name={name}" - cmd = self._build_cmd("stop-runner.yml", host, user, extra_vars, None, ask_become_pass) - self._executor.run(cmd, description=_("Stopping Gitea Runner {name} on {host}", name=name, host=host)) + cmd = self._build_cmd("stop-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass) + self._executor.run(cmd, description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host)) def enable( self, - host: str, - user: str, - name: str | None = None, + name: str, + host: str | None = None, + user: str | None = None, + key: str | None = None, ask_become_pass: bool = False, ) -> None: """Enable a runner instance to start on boot.""" - if not name: - name = host + actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key) extra_vars = f"runner_name={name}" - cmd = self._build_cmd("enable-runner.yml", host, user, extra_vars, None, ask_become_pass) - self._executor.run(cmd, description=_("Enabling Gitea Runner {name} on {host}", name=name, host=host)) + cmd = self._build_cmd("enable-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass) + self._executor.run(cmd, description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host)) def disable( self, - host: str, - user: str, - name: str | None = None, + name: str, + host: str | None = None, + user: str | None = None, + key: str | None = None, token: str | None = None, gitea_url: str = "", - mode: str = "docker", + mode: str | None = None, ask_become_pass: bool = False, ) -> None: """Disable and deregister a runner instance.""" - if not name: - name = host + actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode) if not token: raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)")) - extra_vars = f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={mode}" - cmd = self._build_cmd("disable-runner.yml", host, user, extra_vars, None, ask_become_pass) - self._executor.run(cmd, description=_("Disabling Gitea Runner {name} on {host}", name=name, host=host)) + extra_vars = f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}" + cmd = self._build_cmd("disable-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass) + self._executor.run(cmd, description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host)) def status( self, - host: str, - user: str, - name: str | None = None, - mode: str = "docker", + name: str, + host: str | None = None, + user: str | None = None, + key: str | None = None, + mode: str | None = None, ask_become_pass: bool = False, ) -> None: """Check the status of a runner instance.""" - if not name: - name = host - extra_vars = f"runner_name={name} runner_mode={mode}" - cmd = self._build_cmd("status-runner.yml", host, user, extra_vars, None, ask_become_pass) - self._executor.run(cmd, description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=host)) + actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode) + extra_vars = f"runner_name={name} runner_mode={actual_mode}" + cmd = self._build_cmd("status-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass) + self._executor.run( + cmd, description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host) + ) def remove( self, - host: str, - user: str, - name: str | None = None, + name: str, + host: str | None = None, + user: str | None = None, + key: str | None = None, token: str | None = None, gitea_url: str = "", - mode: str = "docker", + mode: str | None = None, ask_become_pass: bool = False, ) -> None: """Remove a runner instance completely.""" - if not name: - name = host + actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode) if not token: raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)")) - extra_vars = f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={mode}" - cmd = self._build_cmd("remove-runner.yml", host, user, extra_vars, None, ask_become_pass) - self._executor.run(cmd, description=_("Removing Gitea Runner {name} from {host}", name=name, host=host)) + extra_vars = f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}" + cmd = self._build_cmd("remove-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass) + self._executor.run(cmd, description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host)) + + self._registry.remove(name) + + def list_runners(self) -> list[dict[str, str]]: + """Return a list of registered runners with live service status.""" + runners = self._registry.list() + result: list[dict[str, str]] = [] + for name, info in runners.items(): + host = info["host"] + user = info["user"] + key = info.get("key") + mode = info.get("mode", "docker") + try: + stdout = self._executor.run_ad_hoc(host, user, key, "shell", f"systemctl is-active gitea-runner@{name}") + # Filter out ansible noise (SSH warnings etc) + lines = [ln for ln in stdout.splitlines() if ln.strip() and not ln.startswith(("[WARNING]", "ssh:"))] + service_status = lines[-1].strip() if lines else "unknown" + except Exception: + service_status = "unknown" + result.append( + { + "name": name, + "host": host, + "user": user, + "mode": mode, + "status": service_status, + } + ) + return result def _build_cmd( self, diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index f02f69d..76b9bd3 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -229,13 +229,31 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner() - result = runner.invoke(cli, ["start", "host1", "--user", "ubuntu", "--name", "r1"]) + result = runner.invoke(cli, ["start", "r1"]) assert result.exit_code == 0 mock_manager.start.assert_called_once_with( - host="host1", - user="ubuntu", name="r1", - mode="docker", + host=None, + user=None, + key=None, + mode=None, + ask_become_pass=False, + ) + + @patch("gitea_runner_manager.cli.RunnerManager") + def test_start_with_override(self, mock_manager_class: MagicMock) -> None: + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + runner = CliRunner() + result = runner.invoke(cli, ["start", "r1", "--host", "newhost", "--user", "newuser", "--mode", "binary"]) + assert result.exit_code == 0 + mock_manager.start.assert_called_once_with( + name="r1", + host="newhost", + user="newuser", + key=None, + mode="binary", ask_become_pass=False, ) @@ -245,12 +263,13 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner() - result = runner.invoke(cli, ["stop", "host1", "--user", "ubuntu", "--name", "r1"]) + result = runner.invoke(cli, ["stop", "r1"]) assert result.exit_code == 0 mock_manager.stop.assert_called_once_with( - host="host1", - user="ubuntu", name="r1", + host=None, + user=None, + key=None, ask_become_pass=False, ) @@ -260,12 +279,13 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner() - result = runner.invoke(cli, ["enable", "host1", "--user", "ubuntu", "--name", "r1"]) + result = runner.invoke(cli, ["enable", "r1"]) assert result.exit_code == 0 mock_manager.enable.assert_called_once_with( - host="host1", - user="ubuntu", name="r1", + host=None, + user=None, + key=None, ask_become_pass=False, ) @@ -275,15 +295,16 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner(env={"GITEA_URL": "https://git.example.com"}) - result = runner.invoke(cli, ["disable", "host1", "--user", "ubuntu", "--name", "r1", "--token", "tok"]) + result = runner.invoke(cli, ["disable", "r1", "--token", "tok"]) assert result.exit_code == 0 mock_manager.disable.assert_called_once_with( - host="host1", - user="ubuntu", name="r1", + host=None, + user=None, + key=None, token="tok", gitea_url="https://git.example.com", - mode="docker", + mode=None, ask_become_pass=False, ) @@ -291,7 +312,7 @@ class TestCLI: def test_disable_missing_url(self, mock_manager_class: MagicMock) -> None: with patch.dict("os.environ", {"GITEA_REGISTRATION_TOKEN": "tok"}, clear=True): runner = CliRunner() - result = runner.invoke(cli, ["disable", "host1", "--user", "ubuntu", "--token", "tok"]) + result = runner.invoke(cli, ["disable", "r1", "--token", "tok"]) assert result.exit_code != 0 assert "GITEA_URL must be set" in result.output @@ -304,7 +325,7 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner(env={"GITEA_URL": "https://git.example.com"}) - result = runner.invoke(cli, ["disable", "host1", "--user", "ubuntu", "--token", "tok"]) + result = runner.invoke(cli, ["disable", "r1", "--token", "tok"]) assert result.exit_code != 0 assert "fail" in result.output @@ -314,13 +335,14 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner() - result = runner.invoke(cli, ["status", "host1", "--user", "ubuntu", "--name", "r1"]) + result = runner.invoke(cli, ["status", "r1"]) assert result.exit_code == 0 mock_manager.status.assert_called_once_with( - host="host1", - user="ubuntu", name="r1", - mode="docker", + host=None, + user=None, + key=None, + mode=None, ask_become_pass=False, ) @@ -330,15 +352,16 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner(env={"GITEA_URL": "https://git.example.com"}) - result = runner.invoke(cli, ["remove", "host1", "--user", "ubuntu", "--name", "r1", "--token", "tok"]) + result = runner.invoke(cli, ["remove", "r1", "--token", "tok"]) assert result.exit_code == 0 mock_manager.remove.assert_called_once_with( - host="host1", - user="ubuntu", name="r1", + host=None, + user=None, + key=None, token="tok", gitea_url="https://git.example.com", - mode="docker", + mode=None, ask_become_pass=False, ) @@ -351,7 +374,7 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner() - result = runner.invoke(cli, ["start", "host1", "--user", "ubuntu"]) + result = runner.invoke(cli, ["start", "r1"]) assert result.exit_code != 0 assert "fail" in result.output @@ -364,7 +387,7 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner() - result = runner.invoke(cli, ["stop", "host1", "--user", "ubuntu"]) + result = runner.invoke(cli, ["stop", "r1"]) assert result.exit_code != 0 assert "fail" in result.output @@ -377,7 +400,7 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner() - result = runner.invoke(cli, ["enable", "host1", "--user", "ubuntu"]) + result = runner.invoke(cli, ["enable", "r1"]) assert result.exit_code != 0 assert "fail" in result.output @@ -390,7 +413,7 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner() - result = runner.invoke(cli, ["status", "host1", "--user", "ubuntu"]) + result = runner.invoke(cli, ["status", "r1"]) assert result.exit_code != 0 assert "fail" in result.output @@ -398,7 +421,7 @@ class TestCLI: def test_remove_missing_url(self, mock_manager_class: MagicMock) -> None: with patch.dict("os.environ", {"GITEA_REGISTRATION_TOKEN": "tok"}, clear=True): runner = CliRunner() - result = runner.invoke(cli, ["remove", "host1", "--user", "ubuntu", "--token", "tok"]) + result = runner.invoke(cli, ["remove", "r1", "--token", "tok"]) assert result.exit_code != 0 assert "GITEA_URL must be set" in result.output @@ -411,6 +434,46 @@ class TestCLI: mock_manager_class.return_value = mock_manager runner = CliRunner(env={"GITEA_URL": "https://git.example.com"}) - result = runner.invoke(cli, ["remove", "host1", "--user", "ubuntu", "--token", "tok"]) + result = runner.invoke(cli, ["remove", "r1", "--token", "tok"]) + assert result.exit_code != 0 + assert "fail" in result.output + + @patch("gitea_runner_manager.cli.RunnerManager") + def test_list(self, mock_manager_class: MagicMock) -> None: + mock_manager = MagicMock() + mock_manager.list_runners.return_value = [ + {"name": "r1", "host": "10.0.0.1", "user": "ubuntu", "mode": "docker", "status": "active"}, + ] + mock_manager_class.return_value = mock_manager + + runner = CliRunner() + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0 + assert "r1" in result.output + assert "10.0.0.1" in result.output + assert "active" in result.output + mock_manager.list_runners.assert_called_once() + + @patch("gitea_runner_manager.cli.RunnerManager") + def test_list_empty(self, mock_manager_class: MagicMock) -> None: + mock_manager = MagicMock() + mock_manager.list_runners.return_value = [] + mock_manager_class.return_value = mock_manager + + runner = CliRunner() + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0 + assert "No runners registered" in result.output + + @patch("gitea_runner_manager.cli.RunnerManager") + def test_list_error(self, mock_manager_class: MagicMock) -> None: + mock_manager = MagicMock() + from gitea_runner_manager.exceptions import AnsibleError + + mock_manager.list_runners.side_effect = AnsibleError("fail") + mock_manager_class.return_value = mock_manager + + runner = CliRunner() + result = runner.invoke(cli, ["list"]) assert result.exit_code != 0 assert "fail" in result.output diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py index 0835b78..2d8585b 100644 --- a/tests/unit/test_executor.py +++ b/tests/unit/test_executor.py @@ -155,3 +155,45 @@ class TestAnsibleExecutorTranslation: captured = capsys.readouterr() assert "[GRM] Running Ansible playbook" in captured.out # English fallback + + +class TestAnsibleExecutorAdHoc: + def test_run_ad_hoc_success(self, tmp_path: Path) -> None: + executor = AnsibleExecutor(log_dir=tmp_path) + result_mock = MagicMock() + result_mock.stdout = "active\n" + result_mock.returncode = 0 + result_mock.stderr = "" + + with patch("subprocess.run", return_value=result_mock): + result = executor.run_ad_hoc("10.0.0.1", "ubuntu", "/key", "shell", "systemctl is-active svc") + + assert result == "active" + + def test_run_ad_hoc_without_key(self, tmp_path: Path) -> None: + executor = AnsibleExecutor(log_dir=tmp_path) + result_mock = MagicMock() + result_mock.stdout = "inactive\n" + result_mock.returncode = 0 + result_mock.stderr = "" + + with patch("subprocess.run", return_value=result_mock) as mock_run: + result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd") + + assert result == "inactive" + cmd = mock_run.call_args.args[0] + assert "--private-key" not in cmd + + def test_run_ad_hoc_with_become(self, tmp_path: Path) -> None: + executor = AnsibleExecutor(log_dir=tmp_path) + result_mock = MagicMock() + result_mock.stdout = "ok\n" + result_mock.returncode = 0 + result_mock.stderr = "" + + with patch("subprocess.run", return_value=result_mock) as mock_run: + result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=True) + + assert result == "ok" + cmd = mock_run.call_args.args[0] + assert "--become" in cmd diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py new file mode 100644 index 0000000..6569c4c --- /dev/null +++ b/tests/unit/test_registry.py @@ -0,0 +1,85 @@ +"""Unit tests for the runner registry.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from gitea_runner_manager.exceptions import GRMError +from gitea_runner_manager.registry import RunnerRegistry + + +class TestRunnerRegistry: + def test_init_default_path(self) -> None: + reg = RunnerRegistry() + assert reg._path == Path.home() / ".local" / "share" / "grm" / "runners.json" + + def test_add_and_get(self, tmp_path: Path) -> None: + reg = RunnerRegistry(tmp_path / "runners.json") + reg.add("r1", host="10.0.0.1", user="ubuntu", key="/key", mode="docker", gitea_url="https://git.example.com") + info = reg.get("r1") + assert info is not None + assert info["host"] == "10.0.0.1" + assert info["user"] == "ubuntu" + assert info["key"] == "/key" + assert info["mode"] == "docker" + assert info["gitea_url"] == "https://git.example.com" + assert "created_at" in info + + def test_get_missing(self, tmp_path: Path) -> None: + reg = RunnerRegistry(tmp_path / "runners.json") + assert reg.get("missing") is None + + def test_remove(self, tmp_path: Path) -> None: + reg = RunnerRegistry(tmp_path / "runners.json") + reg.add("r1", host="10.0.0.1", user="ubuntu") + reg.remove("r1") + assert reg.get("r1") is None + + def test_list(self, tmp_path: Path) -> None: + reg = RunnerRegistry(tmp_path / "runners.json") + reg.add("r1", host="10.0.0.1", user="ubuntu") + reg.add("r2", host="10.0.0.2", user="root") + runners = reg.list() + assert len(runners) == 2 + assert runners["r1"]["host"] == "10.0.0.1" + assert runners["r2"]["host"] == "10.0.0.2" + + def test_update(self, tmp_path: Path) -> None: + reg = RunnerRegistry(tmp_path / "runners.json") + reg.add("r1", host="10.0.0.1", user="ubuntu") + reg.update("r1", host="10.0.0.2") + info = reg.get("r1") + assert info is not None + assert info["host"] == "10.0.0.2" + assert info["user"] == "ubuntu" + + def test_update_missing(self, tmp_path: Path) -> None: + reg = RunnerRegistry(tmp_path / "runners.json") + with pytest.raises(GRMError, match="not found in registry"): + reg.update("missing", host="10.0.0.1") + + def test_persistence(self, tmp_path: Path) -> None: + path = tmp_path / "runners.json" + reg1 = RunnerRegistry(path) + reg1.add("r1", host="10.0.0.1", user="ubuntu") + reg2 = RunnerRegistry(path) + info = reg2.get("r1") + assert info is not None + assert info["host"] == "10.0.0.1" + + def test_corrupt_file(self, tmp_path: Path) -> None: + path = tmp_path / "runners.json" + path.write_text("not json") + reg = RunnerRegistry(path) + assert reg.list() == {} + + def test_list_returns_copy(self, tmp_path: Path) -> None: + reg = RunnerRegistry(tmp_path / "runners.json") + reg.add("r1", host="10.0.0.1", user="ubuntu") + runners = reg.list() + runners["r1"]["host"] = "modified" + info = reg.get("r1") + assert info is not None + assert info["host"] == "10.0.0.1" diff --git a/tests/unit/test_runner_manager.py b/tests/unit/test_runner_manager.py index 6df834a..0586062 100644 --- a/tests/unit/test_runner_manager.py +++ b/tests/unit/test_runner_manager.py @@ -14,9 +14,11 @@ class TestRunnerManager: manager = RunnerManager() assert manager is not None assert manager._executor is not None + assert manager._registry is not None def test_install_basic(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor @@ -33,9 +35,18 @@ class TestRunnerManager: assert "gitea_url=https://git.example.com" in cmd_str assert "runner_mode=docker" in cmd_str assert "Installing Gitea Runner on 192.168.1.10" in mock_executor.run.call_args.kwargs["description"] + mock_registry.add.assert_called_once_with( + name="192.168.1.10", + host="192.168.1.10", + user="ubuntu", + key=None, + mode="docker", + gitea_url="https://git.example.com", + ) def test_install_with_name_and_key(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor @@ -50,9 +61,18 @@ class TestRunnerManager: assert "runner_name=my-runner" in cmd_str assert "runner_mode=docker" in cmd_str assert "--ask-become-pass" not in cmd_str + mock_registry.add.assert_called_once_with( + name="my-runner", + host="host1", + user="root", + key="/key", + mode="docker", + gitea_url="https://git.example.com", + ) def test_install_ask_become_pass(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor @@ -63,7 +83,8 @@ class TestRunnerManager: assert "runner_mode=docker" in cmd_str def test_install_binary_mode(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor @@ -71,6 +92,9 @@ class TestRunnerManager: cmd = mock_executor.run.call_args.args[0] cmd_str = " ".join(cmd) assert "runner_mode=binary" in cmd_str + mock_registry.add.assert_called_once_with( + name="host1", host="host1", user="root", key=None, mode="binary", gitea_url="https://git.example.com" + ) def test_install_missing_token(self) -> None: manager = RunnerManager() @@ -84,7 +108,8 @@ class TestRunnerManager: manager.install("host", "user", token="tok") def test_install_with_admin_token(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor @@ -94,12 +119,14 @@ class TestRunnerManager: assert "gitea_admin_token=admin-tok" in cmd_str def test_install_ansible_failure(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor mock_executor.run.side_effect = AnsibleError("Ansible failed with exit code 1. See full log: /tmp/test.log") with pytest.raises(AnsibleError, match="Ansible failed with exit code 1"): manager.install("host", "user", token="tok") + mock_registry.add.assert_not_called() def test_update(self) -> None: manager = RunnerManager() @@ -152,12 +179,52 @@ class TestRunnerManager: with pytest.raises(AnsibleError, match="Ansible failed with exit code 2"): manager.update("host", "user") + def test_resolve_runner_from_registry(self) -> None: + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": "/key", "mode": "docker"} + manager = RunnerManager(registry=mock_registry) + host, user, key, mode = manager._resolve_runner("r1") + assert host == "10.0.0.1" + assert user == "ubuntu" + assert key == "/key" + assert mode == "docker" + mock_registry.get.assert_called_once_with("r1") + + def test_resolve_runner_explicit_host_user(self) -> None: + mock_registry = MagicMock() + mock_registry.get.return_value = None + manager = RunnerManager(registry=mock_registry) + host, user, key, mode = manager._resolve_runner("r1", host="10.0.0.2", user="root") + assert host == "10.0.0.2" + assert user == "root" + assert key is None + assert mode == "docker" + + def test_resolve_runner_override(self) -> None: + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": "/key", "mode": "docker"} + manager = RunnerManager(registry=mock_registry) + host, user, key, mode = manager._resolve_runner("r1", host="10.0.0.2", user="root", key="/new", mode="binary") + assert host == "10.0.0.2" + assert user == "root" + assert key == "/new" + assert mode == "binary" + + def test_resolve_runner_not_found(self) -> None: + mock_registry = MagicMock() + mock_registry.get.return_value = None + manager = RunnerManager(registry=mock_registry) + with pytest.raises(AnsibleError, match="not found in registry"): + manager._resolve_runner("missing") + def test_start(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor - manager.start("host", "user", name="r1", mode="docker") + manager.start("r1") cmd = mock_executor.run.call_args.args[0] cmd_str = " ".join(cmd) assert "start-runner.yml" in cmd_str @@ -165,22 +232,40 @@ class TestRunnerManager: assert "runner_mode=docker" in cmd_str assert "Starting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] - def test_start_default_name(self) -> None: - manager = RunnerManager() + def test_start_with_override(self) -> None: + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor - manager.start("myhost", "user") + manager.start("r1", host="newhost", user="newuser", mode="binary") cmd = mock_executor.run.call_args.args[0] cmd_str = " ".join(cmd) - assert "runner_name=myhost" in cmd_str + assert "newhost," in cmd_str + assert "newuser" in cmd_str + assert "runner_mode=binary" in cmd_str - def test_stop(self) -> None: - manager = RunnerManager() + def test_start_no_registry_with_explicit(self) -> None: + mock_registry = MagicMock() + mock_registry.get.return_value = None + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor - manager.stop("host", "user", name="r1") + manager.start("r1", host="host", user="user", mode="docker") + cmd = mock_executor.run.call_args.args[0] + cmd_str = " ".join(cmd) + assert "start-runner.yml" in cmd_str + + def test_stop(self) -> None: + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} + manager = RunnerManager(registry=mock_registry) + mock_executor = MagicMock() + manager._executor = mock_executor + + manager.stop("r1") cmd = mock_executor.run.call_args.args[0] cmd_str = " ".join(cmd) assert "stop-runner.yml" in cmd_str @@ -188,11 +273,13 @@ class TestRunnerManager: assert "Stopping Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] def test_enable(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor - manager.enable("host", "user", name="r1") + manager.enable("r1") cmd = mock_executor.run.call_args.args[0] cmd_str = " ".join(cmd) assert "enable-runner.yml" in cmd_str @@ -200,11 +287,13 @@ class TestRunnerManager: assert "Enabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] def test_disable(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor - manager.disable("host", "user", name="r1", token="tok", gitea_url="https://git.example.com", mode="docker") + manager.disable("r1", token="tok", gitea_url="https://git.example.com") cmd = mock_executor.run.call_args.args[0] cmd_str = " ".join(cmd) assert "disable-runner.yml" in cmd_str @@ -215,16 +304,20 @@ class TestRunnerManager: assert "Disabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] def test_disable_missing_token(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user"} + manager = RunnerManager(registry=mock_registry) with pytest.raises(AnsibleError, match="GITEA_REGISTRATION_TOKEN must be set"): - manager.disable("host", "user", name="r1") + manager.disable("r1") def test_status(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "binary"} + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor - manager.status("host", "user", name="r1", mode="binary") + manager.status("r1") cmd = mock_executor.run.call_args.args[0] cmd_str = " ".join(cmd) assert "status-runner.yml" in cmd_str @@ -233,11 +326,13 @@ class TestRunnerManager: assert "Checking status of Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] def test_remove(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor - manager.remove("host", "user", name="r1", token="tok", gitea_url="https://git.example.com", mode="docker") + manager.remove("r1", token="tok", gitea_url="https://git.example.com") cmd = mock_executor.run.call_args.args[0] cmd_str = " ".join(cmd) assert "remove-runner.yml" in cmd_str @@ -246,51 +341,77 @@ class TestRunnerManager: assert "gitea_url=https://git.example.com" in cmd_str assert "runner_mode=docker" in cmd_str assert "Removing Gitea Runner r1 from host" in mock_executor.run.call_args.kwargs["description"] + mock_registry.remove.assert_called_once_with("r1") def test_remove_missing_token(self) -> None: - manager = RunnerManager() + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user"} + manager = RunnerManager(registry=mock_registry) with pytest.raises(AnsibleError, match="GITEA_REGISTRATION_TOKEN must be set"): - manager.remove("host", "user", name="r1") + manager.remove("r1") - def test_stop_default_name(self) -> None: - manager = RunnerManager() + def test_remove_registry_deleted_on_failure(self) -> None: + mock_registry = MagicMock() + mock_registry.get.return_value = {"host": "host", "user": "user"} + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() manager._executor = mock_executor - manager.stop("myhost", "user") - cmd = mock_executor.run.call_args.args[0] - assert "runner_name=myhost" in " ".join(cmd) + mock_executor.run.side_effect = AnsibleError("fail") - def test_enable_default_name(self) -> None: - manager = RunnerManager() - mock_executor = MagicMock() - manager._executor = mock_executor - manager.enable("myhost", "user") - cmd = mock_executor.run.call_args.args[0] - assert "runner_name=myhost" in " ".join(cmd) + with pytest.raises(AnsibleError): + manager.remove("r1", token="tok") + mock_registry.remove.assert_not_called() - def test_disable_default_name(self) -> None: - manager = RunnerManager() + def test_list_runners(self) -> None: + mock_registry = MagicMock() + mock_registry.list.return_value = { + "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": "/key", "mode": "docker"}, + } + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() + mock_executor.run_ad_hoc.return_value = "active" manager._executor = mock_executor - manager.disable("myhost", "user", token="tok") - cmd = mock_executor.run.call_args.args[0] - assert "runner_name=myhost" in " ".join(cmd) - def test_status_default_name(self) -> None: - manager = RunnerManager() - mock_executor = MagicMock() - manager._executor = mock_executor - manager.status("myhost", "user") - cmd = mock_executor.run.call_args.args[0] - assert "runner_name=myhost" in " ".join(cmd) + runners = manager.list_runners() + assert len(runners) == 1 + assert runners[0]["name"] == "r1" + assert runners[0]["host"] == "10.0.0.1" + assert runners[0]["status"] == "active" + mock_executor.run_ad_hoc.assert_called_once_with( + "10.0.0.1", "ubuntu", "/key", "shell", "systemctl is-active gitea-runner@r1" + ) - def test_remove_default_name(self) -> None: - manager = RunnerManager() + def test_list_runners_empty(self) -> None: + mock_registry = MagicMock() + mock_registry.list.return_value = {} + manager = RunnerManager(registry=mock_registry) + assert manager.list_runners() == [] + + def test_list_runners_unknown_status(self) -> None: + mock_registry = MagicMock() + mock_registry.list.return_value = { + "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"}, + } + manager = RunnerManager(registry=mock_registry) mock_executor = MagicMock() + mock_executor.run_ad_hoc.return_value = "" manager._executor = mock_executor - manager.remove("myhost", "user", token="tok") - cmd = mock_executor.run.call_args.args[0] - assert "runner_name=myhost" in " ".join(cmd) + + runners = manager.list_runners() + assert runners[0]["status"] == "unknown" + + def test_list_runners_ad_hoc_error(self) -> None: + mock_registry = MagicMock() + mock_registry.list.return_value = { + "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"}, + } + manager = RunnerManager(registry=mock_registry) + mock_executor = MagicMock() + mock_executor.run_ad_hoc.side_effect = Exception("ssh fail") + manager._executor = mock_executor + + runners = manager.list_runners() + assert runners[0]["status"] == "unknown" class TestBuildCmd: