Files
grm/src/gitea_runner_manager/cli.py
T
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 55c2746569
CI / quality (pull_request) Failing after 1m4s
CI / molecule-tests (0) (pull_request) Has been skipped
CI / molecule-tests (1) (pull_request) Has been skipped
CI / molecule-tests (2) (pull_request) Has been skipped
refactor: rootless Docker, fix auto-merge, molecule platform matrix
Three major improvements:

1. Rootless Docker refactor: Removes docker/binary modes, unifies to
   rootless Docker with per-runner system users. Each runner gets its
   own rootless Docker daemon, systemd user service, and isolated
   environment. Simplifies CLI (removes --mode option), Ansible role
   (single code path), and molecule scenarios (removes binary scenario).

2. Auto-merge fix: Fixes status check context mismatch in branch
   protection (was requiring "lint", "unit-tests", "molecule-tests" but
   actual contexts are "CI / quality", "CI / molecule-tests*"). Adds
   retry/wait logic to auto_merge.py that polls commit statuses for up
   to 15 minutes before attempting merge, eliminating the chicken-and-egg
   problem where auto-merge would fail because CI hadn't completed yet.

3. Molecule platform matrix: Adds OS platform matrix to CI — all 6
   scenarios now run on all 4 supported OSes (ubuntu-2204, ubuntu-2404,
   debian-12, archlinux) = 24 test pairs distributed across 3 parallel
   runners. Updates distribute_molecule.py to distribute (scenario,
   platform) pairs. Updates Makefile with molecule-all target for
   local multi-platform testing.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 21:02:52 +02:00

342 lines
8.9 KiB
Python

"""Click CLI for Gitea Runner Manager."""
from __future__ import annotations
import functools
import os
from collections.abc import Callable
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from . import __version__
from .exceptions import GRMError
from .i18n import _
from .runner_manager import RunnerManager
load_dotenv(override=True)
def _runner_options(func: Callable[..., Any]) -> Callable[..., Any]:
"""Apply common override options for registry-based lifecycle commands."""
func = click.option(
"--ask-become-pass/--no-ask-become-pass",
default=True,
help=_("Prompt for sudo password (default)"),
)(func)
func = click.option("--key", "-k", help=_("Override SSH key from registry"))(func)
func = click.option("--user", "-u", help=_("Override user from registry"))(func)
func = click.option("--host", help=_("Override host from registry"))(func)
return func
def _handle_errors(msg_key: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Convert GRMError into a Click exception with a translated message."""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except GRMError as e:
raise click.ClickException(_(msg_key, error=e)) from e
return wrapper
return decorator
@click.group(help=_("Gitea Runner Manager — manage Gitea Actions runners."))
@click.version_option(version=__version__)
def cli() -> None:
pass
@cli.command(help=_("Install and configure a Gitea Runner on a remote host."))
@click.argument("host")
@click.option(
"--user",
"-u",
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
)
@click.option("--key", "-k", default=lambda: os.getenv("GITEA_RUNNER_KEY"), help=_("Path to SSH private key"))
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
@click.option(
"--token",
"-t",
default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"),
help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"),
)
@click.option(
"--url",
default=lambda: os.getenv("GITEA_URL", ""),
help=_("Gitea URL (env: GITEA_URL)"),
)
@click.option(
"--admin-token",
"-a",
default=lambda: os.getenv("REPO_TOKEN"),
help=_("Gitea admin API token for integration test (env: REPO_TOKEN)"),
)
@click.option(
"--integration-retries",
"-r",
type=int,
default=lambda: int(os.getenv("GITEA_INTEGRATION_RETRIES", "3")),
help=_("Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)"),
)
@click.option(
"--labels",
"-l",
default=lambda: os.getenv("GITEA_RUNNER_LABELS", ""),
help=_("Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest"),
)
@click.option(
"--ask-become-pass/--no-ask-become-pass",
default=True,
help=_("Prompt for sudo password (default)"),
)
def install(
host: str,
user: str,
key: str | None,
name: str | None,
token: str | None,
url: str,
admin_token: str | None,
integration_retries: int,
labels: str,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
try:
manager.install(
host=host,
user=user,
key=key,
name=name,
token=token,
gitea_url=url,
admin_token=admin_token,
integration_retries=integration_retries,
labels=labels or None,
ask_become_pass=ask_become_pass,
)
except GRMError as e:
raise click.ClickException(_("Installation failed: {error}", error=e)) from e
@cli.command(help=_("Update the Gitea Runner binary on a remote host."))
@click.argument("host")
@click.option(
"--user",
"-u",
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
)
@click.option("--key", "-k", default=lambda: os.getenv("GITEA_RUNNER_KEY"), help=_("Path to SSH private key"))
@click.option("--version", "-v", help=_("Specific Gitea Runner version"))
@click.option(
"--ask-become-pass/--no-ask-become-pass",
default=True,
help=_("Prompt for sudo password (default)"),
)
@_handle_errors("Update failed: {error}")
def update(
host: str,
user: str,
key: str | None,
version: str | None,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.update(
host=host,
user=user,
key=key,
version=version,
ask_become_pass=ask_become_pass,
)
@cli.command(help=_("Start a registered Gitea Runner."))
@click.argument("runner_name")
@_runner_options
@_handle_errors("Start failed: {error}")
def start(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.start(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
)
@cli.command(help=_("Stop a registered Gitea Runner."))
@click.argument("runner_name")
@_runner_options
@_handle_errors("Stop failed: {error}")
def stop(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.stop(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
)
@cli.command(help=_("Enable a registered Gitea Runner to start on boot."))
@click.argument("runner_name")
@_runner_options
@_handle_errors("Enable failed: {error}")
def enable(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.enable(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
)
@cli.command(help=_("Disable a registered Gitea Runner and deregister it."))
@click.argument("runner_name")
@_runner_options
@click.option(
"--token",
"-t",
default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"),
help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"),
)
@click.option(
"--url",
default=lambda: os.getenv("GITEA_URL", ""),
help=_("Gitea URL (env: GITEA_URL)"),
)
@_handle_errors("Disable failed: {error}")
def disable(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
token: str | None,
url: str,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.disable(
name=runner_name,
host=host,
user=user,
key=key,
token=token,
gitea_url=url,
ask_become_pass=ask_become_pass,
)
@cli.command(help=_("Check the status of a registered Gitea Runner."))
@click.argument("runner_name")
@_runner_options
@_handle_errors("Status check failed: {error}")
def status(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.status(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
)
@cli.command(help=_("Remove a registered Gitea Runner completely."))
@click.argument("runner_name")
@_runner_options
@click.option(
"--token",
"-t",
default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"),
help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"),
)
@click.option(
"--url",
default=lambda: os.getenv("GITEA_URL", ""),
help=_("Gitea URL (env: GITEA_URL)"),
)
@click.option(
"--force",
"-f",
is_flag=True,
help=_("Skip remote cleanup and only remove the local registry entry"),
)
@_handle_errors("Remove failed: {error}")
def remove(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
token: str | None,
url: str,
force: bool,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.remove(
name=runner_name,
host=host,
user=user,
key=key,
token=token,
gitea_url=url,
ask_become_pass=ask_become_pass,
force=force,
)
@cli.command(name="list", help=_("List all registered runners with live status."))
@_handle_errors("List failed: {error}")
def list_runners() -> None:
manager = RunnerManager()
runners = manager.list_runners()
if not runners:
click.echo(_("No runners registered. Use 'grm install' to add one."))
return
click.echo(f"{_('NAME'):<18} {_('HOST'):<16} {_('USER'):<10} {_('LABELS'):<30} {_('STATUS')}")
click.echo("-" * 90)
for r in runners:
click.echo(f"{r['name']:<18} {r['host']:<16} {r['user']:<10} {r['labels']:<30} {r['status']}")