"""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