- 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
400 lines
16 KiB
Python
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
|