Files
grm/src/gitea_runner_manager/runner_manager.py
T
emil 1717d55013
Post-merge Vikunja update / vikunja (push) Successful in 5s
CI / quality (push) Successful in 1m5s
CI / molecule-tests (0) (push) Successful in 18m37s
CI / molecule-tests (2) (push) Successful in 18m51s
CI / molecule-tests (1) (push) Successful in 19m6s
Publish Release / publish (push) Failing after 9s
GRM-32: fix: security, dead code, idempotence, and documentation cleanup
2026-06-21 00:14:31 +00:00

433 lines
15 KiB
Python

"""Core logic for managing Gitea runners."""
from __future__ import annotations
import contextlib
import json
import os
import tempfile
from collections.abc import Generator
from contextlib import contextmanager
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()
@contextmanager
def _extra_vars_file(self, extra_vars: dict[str, str | int] | None) -> Generator[str | None, None, None]:
"""Write extra-vars to a temp JSON file with restricted permissions.
Secrets passed via ``--extra-vars`` on the command line are visible
in the process list (CWE-214). This context manager writes them to
a temporary file with ``0600`` permissions and cleans up on exit.
"""
if not extra_vars:
yield None
return
fd, path = tempfile.mkstemp(suffix=".json", prefix="grm-vars-")
try:
with os.fdopen(fd, "w") as f:
json.dump(extra_vars, f)
os.chmod(path, 0o600)
yield path
finally:
with contextlib.suppress(FileNotFoundError):
os.unlink(path)
def _run_playbook(
self,
playbook_name: str,
host: str,
user: str,
extra_vars: dict[str, str | int] | None = None,
key: str | None = None,
ask_become_pass: bool = False,
description: str = "",
) -> None:
"""Build command with temp-file extra-vars and execute via executor."""
with self._extra_vars_file(extra_vars) as vars_file:
cmd = self._build_cmd(playbook_name, host, user, vars_file, key, ask_become_pass)
self._executor.run(cmd, description=description)
def install(
self,
host: str,
user: str,
key: str | None = None,
name: str | None = None,
token: str | None = None,
gitea_url: str = "",
admin_token: str | None = None,
integration_retries: int = 3,
ask_become_pass: bool = False,
labels: str | None = None,
) -> 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: dict[str, str | int] = {
"registration_token": token,
"runner_name": name,
"gitea_url": gitea_url,
"gitea_runner_integration_retries": integration_retries,
}
if admin_token:
extra_vars["gitea_admin_token"] = admin_token
if labels:
extra_vars["runner_labels"] = labels
with track_steps() as tracker:
tracker.begin(_("Installing Gitea Runner on {host}", host=host))
self._run_playbook(
"install-runner.yml",
host,
user,
extra_vars,
key,
ask_become_pass,
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,
gitea_url=gitea_url,
labels=labels,
)
tracker.done()
def update(
self,
host: str,
user: str,
key: str | None = None,
version: str | None = None,
ask_become_pass: bool = False,
) -> None:
"""Update the gitea_runner binary on a remote host."""
extra_vars: dict[str, str | int] | None = None
if version:
extra_vars = {"gitea_runner_version": version}
with track_steps() as tracker:
tracker.begin(_("Updating Gitea Runner on {host}", host=host))
self._run_playbook(
"update-runner.yml",
host,
user,
extra_vars,
key,
ask_become_pass,
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,
) -> tuple[str, str, str | None, str]:
"""Look up runner metadata from registry, applying CLI overrides.
Returns ``(host, user, key, gitea_url)`` where *gitea_url* is
taken from the registry when available, allowing ``disable`` and
``remove`` to reuse the value stored at install time.
"""
info = self._registry.get(name)
actual_gitea_url = info.get("gitea_url", "") if info else ""
if host and user:
# Explicit connection details — bypass registry
actual_host = host
actual_user = user
actual_key = key
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")
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_gitea_url
def start(
self,
name: str,
host: str | None = None,
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
) -> None:
"""Start a runner instance on a remote host."""
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
with track_steps() as tracker:
tracker.begin(_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host))
self._run_playbook(
"start-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
actual_key,
ask_become_pass,
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, _gitea_url = 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))
self._run_playbook(
"stop-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
actual_key,
ask_become_pass,
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, _gitea_url = 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))
self._run_playbook(
"enable-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
actual_key,
ask_become_pass,
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 = "",
ask_become_pass: bool = False,
) -> None:
"""Disable and deregister a runner instance."""
actual_host, actual_user, actual_key, registry_gitea_url = self._resolve_runner(name, host, user, key)
resolved_gitea_url = gitea_url or registry_gitea_url
if not resolved_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)"))
with track_steps() as tracker:
tracker.begin(_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host))
self._run_playbook(
"disable-runner.yml",
actual_host,
actual_user,
{"runner_name": name, "registration_token": token, "gitea_url": resolved_gitea_url},
actual_key,
ask_become_pass,
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,
ask_become_pass: bool = False,
) -> None:
"""Check the status of a runner instance."""
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
with track_steps() as tracker:
tracker.begin(_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host))
self._run_playbook(
"status-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
actual_key,
ask_become_pass,
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 = "",
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.
"""
actual_host, actual_user, actual_key, registry_gitea_url = self._resolve_runner(name, host, user, key)
resolved_gitea_url = gitea_url or registry_gitea_url
resolved_token = token
if not force:
if not resolved_gitea_url:
raise AnsibleError(_("GITEA_URL must be set (or pass --url)"))
if not resolved_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 and resolved_token and resolved_gitea_url:
self._run_playbook(
"remove-runner.yml",
actual_host,
actual_user,
{"runner_name": name, "registration_token": resolved_token, "gitea_url": resolved_gitea_url},
actual_key,
ask_become_pass,
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")
say(
_(
"Checking status of Gitea Runner {name} on {host} as {user} (sudo required)",
name=name,
host=host,
user=user,
)
)
service_status = "unknown"
try:
stdout = self._executor.run_ad_hoc(
host,
user,
key,
"shell",
f"sudo -u grm-{name} systemctl --user is-active gitea-runner 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
)
service_status = self._parse_status(stdout)
except AnsibleError:
service_status = "unknown"
translated_status = _(
service_status if service_status in {"active", "inactive", "failed", "unknown"} else "unknown"
)
result.append(
{
"name": name,
"host": host,
"user": user,
"labels": info.get("labels", ""),
"status": translated_status,
}
)
return result
@staticmethod
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"
def _build_cmd(
self,
playbook_name: str,
host: str,
user: str,
extra_vars_file: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
) -> list[str]:
"""Build the ansible-playbook command.
Extra-vars are passed via ``@tempfile`` to avoid exposing secrets
in the process list (CWE-214).
"""
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,
]
if extra_vars_file:
cmd.extend(["--extra-vars", f"@{extra_vars_file}"])
if key:
cmd.extend(["--private-key", key])
if ask_become_pass:
cmd.append("--ask-become-pass")
return cmd