Files
grm/src/gitea_runner_manager/runner_manager.py
T
Emil Simeonov a9adb71a08 GRM-17: fix docker inspect Jinja2 conflict and add per-iteration exception handling
- docker inspect -f "{{.State.Status}}" used Go template braces that
  conflicted with Ansible Jinja2 templating in the shell module.
  Ansible tried to parse {{.State.Status}} as a Jinja2 variable (which
  starts with a dot, making it invalid), causing a local template error.
  The outer except Exception caught this immediately, so the fallback
  loop never reached the legacy container name or systemctl checks.

- Replaced with: docker inspect <name> | python3 -c JSON parsing,
  which avoids any brace syntax and uses python3 (already required by
  Ansible on managed nodes).

- Added per-iteration try/except inside the fallback loop so a failure
  on one container name continues to the next fallback instead of
  aborting the entire check.

- Added tests for fallback behavior and binary mode exception path.

128 tests, 100% coverage, ruff + pyright clean
2026-06-19 02:53:26 +02:00

400 lines
16 KiB
Python

"""Core logic for managing Gitea runners."""
from __future__ import annotations
from pathlib import Path
from .exceptions import AnsibleError
from .executor import AnsibleExecutor
from .i18n import _
from .registry import RunnerRegistry
from .report import track_steps
from .ui import say
class RunnerManager:
"""Orchestrates runner installation, updates, and lifecycle."""
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,
host: str,
user: str,
key: str | None = None,
name: str | None = None,
token: str | None = None,
gitea_url: str = "",
mode: str = "docker",
admin_token: str | None = None,
integration_retries: int = 3,
ask_become_pass: bool = False,
) -> None:
"""Install a runner on a remote host using Ansible."""
if not name:
name = host
if not gitea_url:
raise AnsibleError(_("GITEA_URL must be set (or pass --url)"))
if not token:
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)"))
extra_vars = (
f"registration_token={token} runner_name={name} gitea_url={gitea_url}"
f" runner_mode={mode}"
f" gitea_runner_integration_retries={integration_retries}"
)
if admin_token:
extra_vars += f" gitea_admin_token={admin_token}"
with track_steps() as tracker:
tracker.begin(_("Installing Gitea Runner on {host}", host=host))
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))
tracker.done()
tracker.begin(_("Save runner '{name}' to local registry", name=name))
self._registry.add(
name=name,
host=host,
user=user,
key=key,
mode=mode,
gitea_url=gitea_url,
)
tracker.done()
def update(
self,
host: str,
user: str,
key: str | None = None,
version: str | None = None,
mode: str = "docker",
ask_become_pass: bool = False,
) -> None:
"""Update the gitea_runner binary on a remote host."""
extra_vars = f"runner_mode={mode}"
if version:
extra_vars += f" gitea_runner_version={version}"
with track_steps() as tracker:
tracker.begin(_("Updating Gitea Runner on {host}", host=host))
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))
tracker.done()
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,
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."""
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
with track_steps() as tracker:
tracker.begin(_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host))
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)
)
tracker.done()
def stop(
self,
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."""
actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key)
with track_steps() as tracker:
tracker.begin(_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host))
extra_vars = f"runner_name={name}"
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)
)
tracker.done()
def enable(
self,
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."""
actual_host, actual_user, actual_key, _mode = self._resolve_runner(name, host, user, key)
with track_steps() as tracker:
tracker.begin(_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host))
extra_vars = f"runner_name={name}"
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)
)
tracker.done()
def disable(
self,
name: str,
host: str | None = None,
user: str | None = None,
key: str | None = None,
token: str | None = None,
gitea_url: str = "",
mode: str | None = None,
ask_become_pass: bool = False,
) -> None:
"""Disable and deregister a runner instance."""
if not gitea_url:
raise AnsibleError(_("GITEA_URL must be set (or pass --url)"))
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)"))
with track_steps() as tracker:
tracker.begin(_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_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)
)
tracker.done()
def status(
self,
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."""
actual_host, actual_user, actual_key, actual_mode = self._resolve_runner(name, host, user, key, mode)
with track_steps() as tracker:
tracker.begin(_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host))
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)
)
tracker.done()
def remove(
self,
name: str,
host: str | None = None,
user: str | None = None,
key: str | None = None,
token: str | None = None,
gitea_url: str = "",
mode: str | None = None,
ask_become_pass: bool = False,
force: bool = False,
) -> None:
"""Remove a runner instance completely.
When *force* is ``True``, skip the remote Ansible playbook and
only remove the local registry entry. Use this when the remote
host is already gone or unreachable.
"""
if not gitea_url:
raise AnsibleError(_("GITEA_URL must be set (or pass --url)"))
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)"))
with track_steps() as tracker:
tracker.begin(_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host))
if not force:
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)
)
tracker.done()
tracker.begin(_("Remove runner '{name}' from local registry", name=name))
self._registry.remove(name)
tracker.done()
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")
say(
_(
"Checking status of Gitea Runner {name} on {host} as {user} (sudo required)",
name=name,
host=host,
user=user,
)
)
def _parse_status(stdout: str) -> str:
ansible_noise = (" | CHANGED | ", " | FAILED | ", " | UNREACHABLE | ", "[WARNING]", "ssh:", ">>")
lines = [ln for ln in stdout.splitlines() if ln.strip() and not any(p in ln for p in ansible_noise)]
return lines[-1].strip() if lines else "unknown"
service_status = "unknown"
if mode == "docker":
# Try expected container name first, then host-based fallback for legacy installs.
# Use python3 JSON parsing to avoid Jinja2 brace conflicts in Ansible shell args.
for container_name in (f"gitea-runner-{name}", f"gitea-runner-{host}"):
try:
stdout = self._executor.run_ad_hoc(
host,
user,
key,
"shell",
(
f"docker inspect {container_name} 2>/dev/null | "
f"python3 -c \"import sys,json; print(json.load(sys.stdin)[0]['State']['Status'])\" "
f"2>/dev/null || echo unknown"
),
become=True,
ask_become_pass=True,
check=False,
)
except Exception:
continue
status = _parse_status(stdout)
if status != "unknown":
docker_map = {"running": "active", "exited": "inactive", "dead": "failed"}
service_status = docker_map.get(status, "unknown")
break
else:
# Final fallback: check systemd service (for installs using the fixed template)
try:
stdout = self._executor.run_ad_hoc(
host,
user,
key,
"shell",
f"systemctl is-active gitea-runner@{name} 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
)
service_status = _parse_status(stdout)
except Exception:
service_status = "unknown"
else:
try:
stdout = self._executor.run_ad_hoc(
host,
user,
key,
"shell",
f"systemctl is-active gitea-runner@{name} 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
)
service_status = _parse_status(stdout)
except Exception:
service_status = "unknown"
# Translate known status values
translated_status = _(
service_status if service_status in {"active", "inactive", "failed", "unknown"} else "unknown"
)
result.append(
{
"name": name,
"host": host,
"user": user,
"mode": mode,
"status": translated_status,
}
)
return result
def _build_cmd(
self,
playbook_name: str,
host: str,
user: str,
extra_vars: str,
key: str | None = None,
ask_become_pass: bool = False,
) -> list[str]:
"""Build the ansible-playbook command."""
playbook = Path(__file__).parent.parent.parent / "ansible" / playbook_name
if not playbook.exists():
raise AnsibleError(_("Playbook not found: {playbook}", playbook=playbook))
cmd = [
"ansible-playbook",
str(playbook),
"-i",
f"{host},",
"-u",
user,
"--extra-vars",
extra_vars,
]
if key:
cmd.extend(["--private-key", key])
if ask_become_pass:
cmd.append("--ask-become-pass")
return cmd