GRM-9: feat: add runner registry for simplified CLI UX

This commit is contained in:
Emil Simeonov
2026-06-19 00:57:26 +02:00
parent a575a89026
commit 4c08606d0c
10 changed files with 869 additions and 244 deletions
+34 -16
View File
@@ -12,6 +12,7 @@ By default, runners are deployed as **Docker containers** using the official `gi
- **Secure** — no hardcoded secrets, uses scoped tokens. - **Secure** — no hardcoded secrets, uses scoped tokens.
- **Idempotent** — can be run multiple times safely. - **Idempotent** — can be run multiple times safely.
- **Flexible** — accepts a plain IP address or hostname, and allows specifying the SSH user and private key. - **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. - **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. - **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@<name>.service`). - **Systemd-managed** — both Docker and binary modes run under systemd template units (`gitea-runner@<name>.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 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 ```bash
# Start a runner (registers if .runner file is missing) # List all registered runners with live systemd status
grm start 192.168.1.10 --user ubuntu --name prod-runner --ask-become-pass grm list
```
# Stop a runner (graceful stop, no deregistration) ### Manage Runner Lifecycle
grm stop 192.168.1.10 --user ubuntu --name prod-runner --ask-become-pass
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 # 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) # Disable a runner (stops, deregisters, and disables systemd)
grm disable 192.168.1.10 --user ubuntu --name prod-runner --token <token> --ask-become-pass grm disable prod-runner --token <token>
# Check runner status # 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) # Remove a runner completely
grm remove 192.168.1.10 --user ubuntu --name prod-runner --token <token> --ask-become-pass grm remove prod-runner --token <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`. > **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 workflow-runner --ask-become-pass
grm install 192.168.1.10 --user ubuntu --name build-runner --mode binary --ask-become-pass grm install 192.168.1.10 --user ubuntu --name build-runner --mode binary --ask-become-pass
# Manage them independently # Manage them independently by name
grm stop 192.168.1.10 --user ubuntu --name workflow-runner --ask-become-pass grm stop workflow-runner
grm status 192.168.1.10 --user ubuntu --name build-runner --ask-become-pass grm status build-runner
``` ```
### Verify 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 ├── src/gitea_runner_manager/ # Python CLI source
│ ├── cli.py # Click commands │ ├── cli.py # Click commands
│ ├── runner_manager.py # Ansible orchestration │ ├── runner_manager.py # Ansible orchestration + registry integration
│ ├── executor.py # Ansible subprocess execution │ ├── executor.py # Ansible subprocess execution
│ ├── registry.py # Local JSON runner registry
│ ├── i18n.py # Translations (en, bg, de, ru, zh) │ ├── i18n.py # Translations (en, bg, de, ru, zh)
│ └── exceptions.py # Custom exceptions │ └── exceptions.py # Custom exceptions
├── ansible/ ├── ansible/
@@ -337,6 +354,7 @@ The test checks two things:
| `disable` | Disables and deregisters a runner | | `disable` | Disables and deregisters a runner |
| `status` | Checks runner status | | `status` | Checks runner status |
| `remove` | Removes a runner completely | | `remove` | Removes a runner completely |
| `list` | Lists registered runners with live status |
| `lint` | Runs Python linters | | `lint` | Runs Python linters |
| `ansible-lint` | Runs `ansible-lint` | | `ansible-lint` | Runs `ansible-lint` |
| `test-unit` | Runs unit tests with coverage | | `test-unit` | Runs unit tests with coverage |
+92 -90
View File
@@ -129,36 +129,33 @@ def update(
raise click.ClickException(_("Update failed: {error}", error=e)) from e raise click.ClickException(_("Update failed: {error}", error=e)) from e
@cli.command(help=_("Start and configure a Gitea Runner on a remote host.")) @cli.command(help=_("Start a registered Gitea Runner."))
@click.argument("host") @click.argument("runner_name")
@click.option( @click.option("--host", help=_("Override host from registry"))
"--user", @click.option("--user", "-u", help=_("Override user from registry"))
"-u", @click.option("--key", "-k", help=_("Override SSH key from registry"))
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
)
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
@click.option( @click.option(
"--mode", "--mode",
"-m", "-m",
type=click.Choice(["docker", "binary"]), type=click.Choice(["docker", "binary"]),
default="docker", help=_("Override deployment mode from registry"),
help=_("Gitea Runner deployment mode (default: docker)"),
) )
@click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password"))
def start( def start(
host: str, runner_name: str,
user: str, host: str | None,
name: str | None, user: str | None,
mode: str, key: str | None,
mode: str | None,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
manager = RunnerManager() manager = RunnerManager()
try: try:
manager.start( manager.start(
name=runner_name,
host=host, host=host,
user=user, user=user,
name=name, key=key,
mode=mode, mode=mode,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
) )
@@ -166,71 +163,63 @@ def start(
raise click.ClickException(_("Start failed: {error}", error=e)) from e raise click.ClickException(_("Start failed: {error}", error=e)) from e
@cli.command(help=_("Stop a Gitea Runner on a remote host.")) @cli.command(help=_("Stop a registered Gitea Runner."))
@click.argument("host") @click.argument("runner_name")
@click.option( @click.option("--host", help=_("Override host from registry"))
"--user", @click.option("--user", "-u", help=_("Override user from registry"))
"-u", @click.option("--key", "-k", help=_("Override SSH key from registry"))
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
)
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
@click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password"))
def stop( def stop(
host: str, runner_name: str,
user: str, host: str | None,
name: str | None, user: str | None,
key: str | None,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
manager = RunnerManager() manager = RunnerManager()
try: try:
manager.stop( manager.stop(
name=runner_name,
host=host, host=host,
user=user, user=user,
name=name, key=key,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
) )
except GRMError as e: except GRMError as e:
raise click.ClickException(_("Stop failed: {error}", error=e)) from e raise click.ClickException(_("Stop failed: {error}", error=e)) from e
@cli.command(help=_("Enable a Gitea Runner to start on boot.")) @cli.command(help=_("Enable a registered Gitea Runner to start on boot."))
@click.argument("host") @click.argument("runner_name")
@click.option( @click.option("--host", help=_("Override host from registry"))
"--user", @click.option("--user", "-u", help=_("Override user from registry"))
"-u", @click.option("--key", "-k", help=_("Override SSH key from registry"))
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
)
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
@click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password"))
def enable( def enable(
host: str, runner_name: str,
user: str, host: str | None,
name: str | None, user: str | None,
key: str | None,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
manager = RunnerManager() manager = RunnerManager()
try: try:
manager.enable( manager.enable(
name=runner_name,
host=host, host=host,
user=user, user=user,
name=name, key=key,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
) )
except GRMError as e: except GRMError as e:
raise click.ClickException(_("Enable failed: {error}", error=e)) from e raise click.ClickException(_("Enable failed: {error}", error=e)) from e
@cli.command(help=_("Disable a Gitea Runner and deregister it.")) @cli.command(help=_("Disable a registered Gitea Runner and deregister it."))
@click.argument("host") @click.argument("runner_name")
@click.option( @click.option("--host", help=_("Override host from registry"))
"--user", @click.option("--user", "-u", help=_("Override user from registry"))
"-u", @click.option("--key", "-k", help=_("Override SSH key from registry"))
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
)
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
@click.option( @click.option(
"--token", "--token",
"-t", "-t",
@@ -241,16 +230,16 @@ def enable(
"--mode", "--mode",
"-m", "-m",
type=click.Choice(["docker", "binary"]), type=click.Choice(["docker", "binary"]),
default="docker", help=_("Override deployment mode from registry"),
help=_("Gitea Runner deployment mode (default: docker)"),
) )
@click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password"))
def disable( def disable(
host: str, runner_name: str,
user: str, host: str | None,
name: str | None, user: str | None,
key: str | None,
token: str | None, token: str | None,
mode: str, mode: str | None,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
gitea_url = os.getenv("GITEA_URL", "") gitea_url = os.getenv("GITEA_URL", "")
@@ -259,9 +248,10 @@ def disable(
manager = RunnerManager() manager = RunnerManager()
try: try:
manager.disable( manager.disable(
name=runner_name,
host=host, host=host,
user=user, user=user,
name=name, key=key,
token=token, token=token,
gitea_url=gitea_url, gitea_url=gitea_url,
mode=mode, mode=mode,
@@ -271,36 +261,33 @@ def disable(
raise click.ClickException(_("Disable failed: {error}", error=e)) from e raise click.ClickException(_("Disable failed: {error}", error=e)) from e
@cli.command(help=_("Check the status of a Gitea Runner.")) @cli.command(help=_("Check the status of a registered Gitea Runner."))
@click.argument("host") @click.argument("runner_name")
@click.option( @click.option("--host", help=_("Override host from registry"))
"--user", @click.option("--user", "-u", help=_("Override user from registry"))
"-u", @click.option("--key", "-k", help=_("Override SSH key from registry"))
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
)
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
@click.option( @click.option(
"--mode", "--mode",
"-m", "-m",
type=click.Choice(["docker", "binary"]), type=click.Choice(["docker", "binary"]),
default="docker", help=_("Override deployment mode from registry"),
help=_("Gitea Runner deployment mode (default: docker)"),
) )
@click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password"))
def status( def status(
host: str, runner_name: str,
user: str, host: str | None,
name: str | None, user: str | None,
mode: str, key: str | None,
mode: str | None,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
manager = RunnerManager() manager = RunnerManager()
try: try:
manager.status( manager.status(
name=runner_name,
host=host, host=host,
user=user, user=user,
name=name, key=key,
mode=mode, mode=mode,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
) )
@@ -308,15 +295,11 @@ def status(
raise click.ClickException(_("Status check failed: {error}", error=e)) from e raise click.ClickException(_("Status check failed: {error}", error=e)) from e
@cli.command(help=_("Remove a Gitea Runner completely.")) @cli.command(help=_("Remove a registered Gitea Runner completely."))
@click.argument("host") @click.argument("runner_name")
@click.option( @click.option("--host", help=_("Override host from registry"))
"--user", @click.option("--user", "-u", help=_("Override user from registry"))
"-u", @click.option("--key", "-k", help=_("Override SSH key from registry"))
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
)
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
@click.option( @click.option(
"--token", "--token",
"-t", "-t",
@@ -327,16 +310,16 @@ def status(
"--mode", "--mode",
"-m", "-m",
type=click.Choice(["docker", "binary"]), type=click.Choice(["docker", "binary"]),
default="docker", help=_("Override deployment mode from registry"),
help=_("Gitea Runner deployment mode (default: docker)"),
) )
@click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password")) @click.option("--ask-become-pass", is_flag=True, help=_("Prompt for sudo password"))
def remove( def remove(
host: str, runner_name: str,
user: str, host: str | None,
name: str | None, user: str | None,
key: str | None,
token: str | None, token: str | None,
mode: str, mode: str | None,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
gitea_url = os.getenv("GITEA_URL", "") gitea_url = os.getenv("GITEA_URL", "")
@@ -345,9 +328,10 @@ def remove(
manager = RunnerManager() manager = RunnerManager()
try: try:
manager.remove( manager.remove(
name=runner_name,
host=host, host=host,
user=user, user=user,
name=name, key=key,
token=token, token=token,
gitea_url=gitea_url, gitea_url=gitea_url,
mode=mode, mode=mode,
@@ -355,3 +339,21 @@ def remove(
) )
except GRMError as e: except GRMError as e:
raise click.ClickException(_("Remove failed: {error}", error=e)) from 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']}")
+34
View File
@@ -73,6 +73,40 @@ class AnsibleExecutor:
proc.wait() proc.wait()
return proc.returncode 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: def _extract_status(self, log_file: Path) -> str | None:
"""Extract the runner status message from the Ansible log.""" """Extract the runner status message from the Ansible log."""
try: try:
+101
View File
@@ -303,6 +303,107 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
"ru": "Команда: {cmd}", "ru": "Команда: {cmd}",
"zh": "命令: {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}",
},
} }
+83
View File
@@ -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()
+128 -52
View File
@@ -7,13 +7,19 @@ from pathlib import Path
from .exceptions import AnsibleError from .exceptions import AnsibleError
from .executor import AnsibleExecutor from .executor import AnsibleExecutor
from .i18n import _ from .i18n import _
from .registry import RunnerRegistry
class RunnerManager: 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._executor = executor or AnsibleExecutor()
self._registry = registry or RunnerRegistry()
def install( def install(
self, self,
@@ -45,6 +51,15 @@ class RunnerManager:
cmd = self._build_cmd("install-runner.yml", host, user, extra_vars, key, ask_become_pass) 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._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( def update(
self, self,
host: str, host: str,
@@ -62,101 +77,162 @@ class RunnerManager:
cmd = self._build_cmd("update-runner.yml", host, user, extra_vars, key, ask_become_pass) 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)) 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( def start(
self, self,
host: str, name: str,
user: str, host: str | None = None,
name: str | None = None, user: str | None = None,
mode: str = "docker", key: str | None = None,
mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Start a runner instance on a remote host.""" """Start a runner instance on a remote host."""
if not name: actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
name = host extra_vars = f"runner_name={name} runner_mode={actual_mode}"
extra_vars = f"runner_name={name} runner_mode={mode}" cmd = self._build_cmd("start-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
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=actual_host))
self._executor.run(cmd, description=_("Starting Gitea Runner {name} on {host}", name=name, host=host))
def stop( def stop(
self, self,
host: str, name: str,
user: str, host: str | None = None,
name: str | None = None, user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Stop a runner instance on a remote host.""" """Stop a runner instance on a remote host."""
if not name: actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key)
name = host
extra_vars = f"runner_name={name}" extra_vars = f"runner_name={name}"
cmd = self._build_cmd("stop-runner.yml", host, user, extra_vars, None, ask_become_pass) 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=host)) self._executor.run(cmd, description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host))
def enable( def enable(
self, self,
host: str, name: str,
user: str, host: str | None = None,
name: str | None = None, user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Enable a runner instance to start on boot.""" """Enable a runner instance to start on boot."""
if not name: actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key)
name = host
extra_vars = f"runner_name={name}" extra_vars = f"runner_name={name}"
cmd = self._build_cmd("enable-runner.yml", host, user, extra_vars, None, ask_become_pass) 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=host)) self._executor.run(cmd, description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host))
def disable( def disable(
self, self,
host: str, name: str,
user: str, host: str | None = None,
name: str | None = None, user: str | None = None,
key: str | None = None,
token: str | None = None, token: str | None = None,
gitea_url: str = "", gitea_url: str = "",
mode: str = "docker", mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Disable and deregister a runner instance.""" """Disable and deregister a runner instance."""
if not name: actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
name = host
if not token: if not token:
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --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}" extra_vars = f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}"
cmd = self._build_cmd("disable-runner.yml", host, user, extra_vars, None, ask_become_pass) 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=host)) self._executor.run(cmd, description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host))
def status( def status(
self, self,
host: str, name: str,
user: str, host: str | None = None,
name: str | None = None, user: str | None = None,
mode: str = "docker", key: str | None = None,
mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Check the status of a runner instance.""" """Check the status of a runner instance."""
if not name: actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
name = host extra_vars = f"runner_name={name} runner_mode={actual_mode}"
extra_vars = f"runner_name={name} runner_mode={mode}" cmd = self._build_cmd("status-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
cmd = self._build_cmd("status-runner.yml", host, user, extra_vars, None, ask_become_pass) self._executor.run(
self._executor.run(cmd, description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=host)) cmd, description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host)
)
def remove( def remove(
self, self,
host: str, name: str,
user: str, host: str | None = None,
name: str | None = None, user: str | None = None,
key: str | None = None,
token: str | None = None, token: str | None = None,
gitea_url: str = "", gitea_url: str = "",
mode: str = "docker", mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Remove a runner instance completely.""" """Remove a runner instance completely."""
if not name: actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
name = host
if not token: if not token:
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --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}" extra_vars = f"runner_name={name} registration_token={token} gitea_url={gitea_url} runner_mode={actual_mode}"
cmd = self._build_cmd("remove-runner.yml", host, user, extra_vars, None, ask_become_pass) 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=host)) 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( def _build_cmd(
self, self,
+93 -30
View File
@@ -229,13 +229,31 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["start", "host1", "--user", "ubuntu", "--name", "r1"]) result = runner.invoke(cli, ["start", "r1"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.start.assert_called_once_with( mock_manager.start.assert_called_once_with(
host="host1",
user="ubuntu",
name="r1", 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, ask_become_pass=False,
) )
@@ -245,12 +263,13 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["stop", "host1", "--user", "ubuntu", "--name", "r1"]) result = runner.invoke(cli, ["stop", "r1"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.stop.assert_called_once_with( mock_manager.stop.assert_called_once_with(
host="host1",
user="ubuntu",
name="r1", name="r1",
host=None,
user=None,
key=None,
ask_become_pass=False, ask_become_pass=False,
) )
@@ -260,12 +279,13 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["enable", "host1", "--user", "ubuntu", "--name", "r1"]) result = runner.invoke(cli, ["enable", "r1"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.enable.assert_called_once_with( mock_manager.enable.assert_called_once_with(
host="host1",
user="ubuntu",
name="r1", name="r1",
host=None,
user=None,
key=None,
ask_become_pass=False, ask_become_pass=False,
) )
@@ -275,15 +295,16 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com"}) 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 assert result.exit_code == 0
mock_manager.disable.assert_called_once_with( mock_manager.disable.assert_called_once_with(
host="host1",
user="ubuntu",
name="r1", name="r1",
host=None,
user=None,
key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker", mode=None,
ask_become_pass=False, ask_become_pass=False,
) )
@@ -291,7 +312,7 @@ class TestCLI:
def test_disable_missing_url(self, mock_manager_class: MagicMock) -> None: def test_disable_missing_url(self, mock_manager_class: MagicMock) -> None:
with patch.dict("os.environ", {"GITEA_REGISTRATION_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"GITEA_REGISTRATION_TOKEN": "tok"}, clear=True):
runner = CliRunner() 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 result.exit_code != 0
assert "GITEA_URL must be set" in result.output assert "GITEA_URL must be set" in result.output
@@ -304,7 +325,7 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com"}) 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 result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@@ -314,13 +335,14 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["status", "host1", "--user", "ubuntu", "--name", "r1"]) result = runner.invoke(cli, ["status", "r1"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.status.assert_called_once_with( mock_manager.status.assert_called_once_with(
host="host1",
user="ubuntu",
name="r1", name="r1",
mode="docker", host=None,
user=None,
key=None,
mode=None,
ask_become_pass=False, ask_become_pass=False,
) )
@@ -330,15 +352,16 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com"}) 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 assert result.exit_code == 0
mock_manager.remove.assert_called_once_with( mock_manager.remove.assert_called_once_with(
host="host1",
user="ubuntu",
name="r1", name="r1",
host=None,
user=None,
key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker", mode=None,
ask_become_pass=False, ask_become_pass=False,
) )
@@ -351,7 +374,7 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["start", "host1", "--user", "ubuntu"]) result = runner.invoke(cli, ["start", "r1"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@@ -364,7 +387,7 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["stop", "host1", "--user", "ubuntu"]) result = runner.invoke(cli, ["stop", "r1"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@@ -377,7 +400,7 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["enable", "host1", "--user", "ubuntu"]) result = runner.invoke(cli, ["enable", "r1"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@@ -390,7 +413,7 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["status", "host1", "--user", "ubuntu"]) result = runner.invoke(cli, ["status", "r1"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
@@ -398,7 +421,7 @@ class TestCLI:
def test_remove_missing_url(self, mock_manager_class: MagicMock) -> None: def test_remove_missing_url(self, mock_manager_class: MagicMock) -> None:
with patch.dict("os.environ", {"GITEA_REGISTRATION_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"GITEA_REGISTRATION_TOKEN": "tok"}, clear=True):
runner = CliRunner() 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 result.exit_code != 0
assert "GITEA_URL must be set" in result.output assert "GITEA_URL must be set" in result.output
@@ -411,6 +434,46 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com"}) 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 result.exit_code != 0
assert "fail" in result.output assert "fail" in result.output
+42
View File
@@ -155,3 +155,45 @@ class TestAnsibleExecutorTranslation:
captured = capsys.readouterr() captured = capsys.readouterr()
assert "[GRM] Running Ansible playbook" in captured.out # English fallback 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
+85
View File
@@ -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"
+177 -56
View File
@@ -14,9 +14,11 @@ class TestRunnerManager:
manager = RunnerManager() manager = RunnerManager()
assert manager is not None assert manager is not None
assert manager._executor is not None assert manager._executor is not None
assert manager._registry is not None
def test_install_basic(self) -> None: def test_install_basic(self) -> None:
manager = RunnerManager() mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -33,9 +35,18 @@ class TestRunnerManager:
assert "gitea_url=https://git.example.com" in cmd_str assert "gitea_url=https://git.example.com" in cmd_str
assert "runner_mode=docker" 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"] 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: def test_install_with_name_and_key(self) -> None:
manager = RunnerManager() mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -50,9 +61,18 @@ class TestRunnerManager:
assert "runner_name=my-runner" in cmd_str assert "runner_name=my-runner" in cmd_str
assert "runner_mode=docker" in cmd_str assert "runner_mode=docker" in cmd_str
assert "--ask-become-pass" not 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: def test_install_ask_become_pass(self) -> None:
manager = RunnerManager() mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -63,7 +83,8 @@ class TestRunnerManager:
assert "runner_mode=docker" in cmd_str assert "runner_mode=docker" in cmd_str
def test_install_binary_mode(self) -> None: def test_install_binary_mode(self) -> None:
manager = RunnerManager() mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -71,6 +92,9 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0] cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "runner_mode=binary" in cmd_str 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: def test_install_missing_token(self) -> None:
manager = RunnerManager() manager = RunnerManager()
@@ -84,7 +108,8 @@ class TestRunnerManager:
manager.install("host", "user", token="tok") manager.install("host", "user", token="tok")
def test_install_with_admin_token(self) -> None: def test_install_with_admin_token(self) -> None:
manager = RunnerManager() mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -94,12 +119,14 @@ class TestRunnerManager:
assert "gitea_admin_token=admin-tok" in cmd_str assert "gitea_admin_token=admin-tok" in cmd_str
def test_install_ansible_failure(self) -> None: def test_install_ansible_failure(self) -> None:
manager = RunnerManager() mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
mock_executor.run.side_effect = AnsibleError("Ansible failed with exit code 1. See full log: /tmp/test.log") 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"): with pytest.raises(AnsibleError, match="Ansible failed with exit code 1"):
manager.install("host", "user", token="tok") manager.install("host", "user", token="tok")
mock_registry.add.assert_not_called()
def test_update(self) -> None: def test_update(self) -> None:
manager = RunnerManager() manager = RunnerManager()
@@ -152,12 +179,52 @@ class TestRunnerManager:
with pytest.raises(AnsibleError, match="Ansible failed with exit code 2"): with pytest.raises(AnsibleError, match="Ansible failed with exit code 2"):
manager.update("host", "user") 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: 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() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
manager.start("host", "user", name="r1", mode="docker") manager.start("r1")
cmd = mock_executor.run.call_args.args[0] cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "start-runner.yml" in cmd_str assert "start-runner.yml" in cmd_str
@@ -165,22 +232,40 @@ class TestRunnerManager:
assert "runner_mode=docker" in cmd_str assert "runner_mode=docker" in cmd_str
assert "Starting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] assert "Starting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_start_default_name(self) -> None: def test_start_with_override(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() mock_executor = MagicMock()
manager._executor = mock_executor 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 = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) 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: def test_start_no_registry_with_explicit(self) -> None:
manager = RunnerManager() mock_registry = MagicMock()
mock_registry.get.return_value = None
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor 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 = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "stop-runner.yml" in cmd_str 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"] assert "Stopping Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_enable(self) -> None: 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() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
manager.enable("host", "user", name="r1") manager.enable("r1")
cmd = mock_executor.run.call_args.args[0] cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "enable-runner.yml" in cmd_str 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"] assert "Enabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_disable(self) -> None: 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() mock_executor = MagicMock()
manager._executor = mock_executor 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 = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "disable-runner.yml" in cmd_str 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"] assert "Disabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_disable_missing_token(self) -> None: 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"): 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: 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() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
manager.status("host", "user", name="r1", mode="binary") manager.status("r1")
cmd = mock_executor.run.call_args.args[0] cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "status-runner.yml" in cmd_str 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"] assert "Checking status of Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_remove(self) -> None: 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() mock_executor = MagicMock()
manager._executor = mock_executor 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 = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "remove-runner.yml" in cmd_str assert "remove-runner.yml" in cmd_str
@@ -246,51 +341,77 @@ class TestRunnerManager:
assert "gitea_url=https://git.example.com" in cmd_str assert "gitea_url=https://git.example.com" in cmd_str
assert "runner_mode=docker" 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"] 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: 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"): 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: def test_remove_registry_deleted_on_failure(self) -> None:
manager = RunnerManager() mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user"}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
manager.stop("myhost", "user") mock_executor.run.side_effect = AnsibleError("fail")
cmd = mock_executor.run.call_args.args[0]
assert "runner_name=myhost" in " ".join(cmd)
def test_enable_default_name(self) -> None: with pytest.raises(AnsibleError):
manager = RunnerManager() manager.remove("r1", token="tok")
mock_executor = MagicMock() mock_registry.remove.assert_not_called()
manager._executor = mock_executor
manager.enable("myhost", "user")
cmd = mock_executor.run.call_args.args[0]
assert "runner_name=myhost" in " ".join(cmd)
def test_disable_default_name(self) -> None: def test_list_runners(self) -> None:
manager = RunnerManager() 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 = MagicMock()
mock_executor.run_ad_hoc.return_value = "active"
manager._executor = mock_executor 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: runners = manager.list_runners()
manager = RunnerManager() assert len(runners) == 1
mock_executor = MagicMock() assert runners[0]["name"] == "r1"
manager._executor = mock_executor assert runners[0]["host"] == "10.0.0.1"
manager.status("myhost", "user") assert runners[0]["status"] == "active"
cmd = mock_executor.run.call_args.args[0] mock_executor.run_ad_hoc.assert_called_once_with(
assert "runner_name=myhost" in " ".join(cmd) "10.0.0.1", "ubuntu", "/key", "shell", "systemctl is-active gitea-runner@r1"
)
def test_remove_default_name(self) -> None: def test_list_runners_empty(self) -> None:
manager = RunnerManager() 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 = MagicMock()
mock_executor.run_ad_hoc.return_value = ""
manager._executor = mock_executor manager._executor = mock_executor
manager.remove("myhost", "user", token="tok")
cmd = mock_executor.run.call_args.args[0] runners = manager.list_runners()
assert "runner_name=myhost" in " ".join(cmd) 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: class TestBuildCmd: