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
433 lines
15 KiB
Python
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
|