diff --git a/ansible/roles/gitea_runner/defaults/main.yml b/ansible/roles/gitea_runner/defaults/main.yml index 184a1bd..3a5041c 100644 --- a/ansible/roles/gitea_runner/defaults/main.yml +++ b/ansible/roles/gitea_runner/defaults/main.yml @@ -3,6 +3,13 @@ gitea_runner_version: "2.0.1" gitea_runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04" gitea_runner_skip_registration: false +# Force re-registration even if .runner file exists. +# Use this when Gitea no longer recognizes the runner (e.g., after a Gitea +# server restore/reinstall or when the runner record was deleted from the +# admin UI). The existing .runner file is removed and a new registration is +# performed. Requires registration_token. +gitea_runner_force_reregister: false + # Per-runner user (rootless isolation) gitea_runner_user_prefix: "grm-" gitea_runner_base_home: "/home" @@ -37,6 +44,18 @@ gitea_runner_healthcheck_boot_delay: "2min" gitea_runner_healthcheck_disk_threshold: 75 gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck.sh" +# Auto-recovery: when the healthcheck detects an unregistered runner, it +# can automatically re-register if a Gitea API token is provided. +# The token needs admin or org-level access to fetch registration tokens. +# Stored in a file readable by the runner user (mode 0400). +# Set to empty string to disable auto-recovery (manual re-registration required). +gitea_runner_auto_recover_api_token: "" + +# Cooldown file to prevent auto-recovery loops (e.g., if Gitea is down). +# The healthcheck writes a timestamp to this file after a re-registration +# attempt and skips further attempts for the cooldown period. +gitea_runner_auto_recover_cooldown_sec: 300 + # Docker daemon resilience settings (applied to daemon.json). # live-restore: containers survive daemon restarts — prevents stuck container # states when the healthcheck restarts a hung daemon. diff --git a/ansible/roles/gitea_runner/tasks/healthcheck.yml b/ansible/roles/gitea_runner/tasks/healthcheck.yml index a85d4e1..30bd8c7 100644 --- a/ansible/roles/gitea_runner/tasks/healthcheck.yml +++ b/ansible/roles/gitea_runner/tasks/healthcheck.yml @@ -7,6 +7,22 @@ group: "{{ gitea_runner_service_user }}" mode: "0755" +- name: Write auto-recovery API token file + ansible.builtin.copy: + content: "{{ gitea_runner_auto_recover_api_token }}" + dest: "{{ gitea_runner_config_dir }}/auto-recover.token" + owner: "{{ gitea_runner_service_user }}" + group: "{{ gitea_runner_service_user }}" + mode: "0400" + no_log: true + when: gitea_runner_auto_recover_api_token | length > 0 + +- name: Remove stale auto-recovery token file (if auto-recovery disabled) + ansible.builtin.file: + path: "{{ gitea_runner_config_dir }}/auto-recover.token" + state: absent + when: gitea_runner_auto_recover_api_token | length == 0 + - name: Create healthcheck user service file ansible.builtin.template: src: runner-healthcheck.service.j2 diff --git a/ansible/roles/gitea_runner/tasks/register.yml b/ansible/roles/gitea_runner/tasks/register.yml index 152d921..d0a9248 100644 --- a/ansible/roles/gitea_runner/tasks/register.yml +++ b/ansible/roles/gitea_runner/tasks/register.yml @@ -7,11 +7,20 @@ group: "{{ gitea_runner_service_user }}" mode: "0755" -- name: Check if runner is already registered +- name: Check if runner registration file exists ansible.builtin.stat: path: "{{ gitea_runner_data_dir }}/.runner" register: gitea_runner_registered +- name: Remove stale runner registration file + ansible.builtin.file: + path: "{{ gitea_runner_data_dir }}/.runner" + state: absent + when: + - gitea_runner_registered.stat.exists + - gitea_runner_force_reregister | bool + register: gitea_runner_registration_removed + - name: Register runner with Gitea ansible.builtin.command: > {{ gitea_runner_binary_path }} register @@ -28,7 +37,23 @@ XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}" DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus" DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock" - when: not gitea_runner_registered.stat.exists + when: not gitea_runner_registered.stat.exists or gitea_runner_force_reregister | bool register: gitea_runner_register_output - changed_when: "'already exists' not in gitea_runner_register_output.stdout | default('')" + changed_when: >- + gitea_runner_register_output.rc == 0 and + ('already exists' not in gitea_runner_register_output.stdout | default('')) timeout: 60 + +- name: Ensure runner service is running after registration + ansible.builtin.command: systemctl --user start gitea-runner + become: true + become_user: "{{ gitea_runner_service_user }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus" + changed_when: true + when: + - gitea_runner_systemd_available.stat.exists + - gitea_runner_docker_rootless_setup + - gitea_runner_register_output is defined + - gitea_runner_register_output.rc | default(1) == 0 diff --git a/ansible/roles/gitea_runner/tasks/rootless_docker.yml b/ansible/roles/gitea_runner/tasks/rootless_docker.yml index 42ae55f..8f087dc 100644 --- a/ansible/roles/gitea_runner/tasks/rootless_docker.yml +++ b/ansible/roles/gitea_runner/tasks/rootless_docker.yml @@ -53,72 +53,6 @@ register: gitea_runner_docker_install when: ansible_facts['os_family'] == 'Debian' -# Docker 28.x vendors containerd v2.1.x. containerd.io >= 2.3 ships a shim that -# returns a protobuf BootstrapResult the vendored 2.1.x code cannot parse, causing -# "failed to create TTRPC connection: unsupported protocol" on every container start. -# Detect the mismatch and downgrade containerd.io to the latest compatible 2.2.x. -- name: Check installed Docker CE version (Debian/Ubuntu) - ansible.builtin.command: dpkg-query -W -f='${Version}' docker-ce - register: gitea_runner_docker_version_check - changed_when: false - when: ansible_facts['os_family'] == 'Debian' - -- name: Check installed containerd.io version (Debian/Ubuntu) - ansible.builtin.shell: "set -o pipefail; dpkg-query -W -f='${Version}' containerd.io 2>/dev/null | cut -d: -f2 | cut -d- -f1" - args: - executable: /bin/bash - register: gitea_runner_containerd_version_check - changed_when: false - when: ansible_facts['os_family'] == 'Debian' - -- name: Determine if containerd.io is incompatible with installed Docker - ansible.builtin.set_fact: - gitea_runner_containerd_needs_downgrade: >- - {{ - gitea_runner_containerd_version_check.stdout.split('.')[0] | int > gitea_runner_containerd_max_compatible_major - or ( - gitea_runner_containerd_version_check.stdout.split('.')[0] | int == gitea_runner_containerd_max_compatible_major - and gitea_runner_containerd_version_check.stdout.split('.')[1] | int > gitea_runner_containerd_max_compatible_minor - ) - }} - gitea_runner_docker_major: "{{ gitea_runner_docker_version_check.stdout.split('.')[0] | default('0') | int }}" - when: ansible_facts['os_family'] == 'Debian' - -- name: Find latest compatible containerd.io version (Debian/Ubuntu) - ansible.builtin.shell: | - set -o pipefail - apt-cache madison containerd.io \ - | awk -F'|' '{print $2}' \ - | tr -d ' ' \ - | grep -E '^{{ gitea_runner_containerd_max_compatible_major }}\.{{ gitea_runner_containerd_max_compatible_minor }}\.' \ - | head -1 - args: - executable: /bin/bash - register: gitea_runner_containerd_compatible_version - changed_when: false - when: - - ansible_facts['os_family'] == 'Debian' - - gitea_runner_containerd_needs_downgrade | default(false) - - gitea_runner_docker_major | int < 29 - -- name: Downgrade containerd.io to compatible version (Debian/Ubuntu) - ansible.builtin.apt: - name: "containerd.io={{ gitea_runner_containerd_compatible_version.stdout }}" - state: present - allow_downgrades: true - register: gitea_runner_containerd_downgrade - when: - - ansible_facts['os_family'] == 'Debian' - - gitea_runner_containerd_needs_downgrade | default(false) - - gitea_runner_docker_major | int < 29 - - gitea_runner_containerd_compatible_version.stdout | length > 0 - -- name: Hold containerd.io package to prevent auto-upgrade (Debian/Ubuntu) - ansible.builtin.dpkg_selections: - name: containerd.io - selection: hold - when: ansible_facts['os_family'] == 'Debian' - - name: Update pacman cache (Arch Linux) community.general.pacman: update_cache: true diff --git a/ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2 b/ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2 index d8b865e..2bc7b12 100644 --- a/ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2 +++ b/ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2 @@ -44,6 +44,117 @@ if [[ "$runner_state" != "active" ]]; then echo "RECOVERED: gitea-runner service restarted successfully" fi +# 2b. Detect unregistered runner state. When Gitea no longer recognizes the +# runner (e.g., server restore, runner record deleted, Gitea restart with +# token salt change), the runner logs "unregistered runner" every few seconds. +# A service restart will not fix this; re-registration is required. +{% if gitea_runner_auto_recover_api_token %} +# Auto-recovery is enabled: fetch a new registration token from the Gitea API +# and re-register the runner automatically. A cooldown prevents infinite loops. +GITEA_API_TOKEN_FILE="{{ gitea_runner_config_dir }}/auto-recover.token" +COOLDOWN_FILE="{{ gitea_runner_data_dir }}/auto-recover.cooldown" +COOLDOWN_SEC={{ gitea_runner_auto_recover_cooldown_sec }} +GITEA_URL="{{ gitea_url }}" +RUNNER_NAME="{{ gitea_runner_name }}" +RUNNER_LABELS="{{ gitea_runner_labels }}" +BINARY="{{ gitea_runner_binary_path }}" +{% endif %} +recent_errors=$(journalctl --user -u gitea-runner.service --since "5 minutes ago" --no-pager -q 2>/dev/null | grep -c "unregistered runner" || true) +if [[ "$recent_errors" -ge 3 ]]; then + echo "CRITICAL: runner is unregistered in Gitea (re-login failed $recent_errors times in 5 minutes)." +{% if gitea_runner_auto_recover_api_token %} + # Check cooldown — skip if we recently attempted recovery + if [[ -f "$COOLDOWN_FILE" ]]; then + last_attempt=$(cat "$COOLDOWN_FILE" 2>/dev/null || echo 0) + now=$(date +%s) + elapsed=$((now - last_attempt)) + if [[ "$elapsed" -lt "$COOLDOWN_SEC" ]]; then + echo "SKIP: auto-recovery cooldown active (${elapsed}s < ${COOLDOWN_SEC}s). Will retry later." + exit 3 + fi + fi + + # Mark attempt time BEFORE trying (so failures also get cooldown) + date +%s > "$COOLDOWN_FILE" 2>/dev/null || true + + # Read the API token + if [[ ! -f "$GITEA_API_TOKEN_FILE" ]]; then + echo "ERROR: auto-recover token file not found at $GITEA_API_TOKEN_FILE. Manual re-registration required." + exit 3 + fi + API_TOKEN=$(cat "$GITEA_API_TOKEN_FILE" 2>/dev/null || true) + if [[ -z "$API_TOKEN" ]]; then + echo "ERROR: auto-recover token file is empty. Manual re-registration required." + exit 3 + fi + + echo "ATTEMPT: auto-recovering by fetching new registration token and re-registering..." + + # Fetch a new registration token from the Gitea API + # Try org-level first (for org-scoped runners), then instance-level + REG_TOKEN="" + for endpoint in \ + "api/v1/orgs/{{ gitea_runner_org | default('oblachno') }}/actions/runners/registration-token" \ + "api/v1/admin/actions/runners/registration-token"; do + REG_TOKEN=$(curl -sf --connect-timeout 5 --max-time 10 -X POST \ + -H "Authorization: token $API_TOKEN" \ + "${GITEA_URL}/${endpoint}" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true) + if [[ -n "$REG_TOKEN" ]]; then + echo "INFO: fetched registration token from ${endpoint}" + break + fi + done + + if [[ -z "$REG_TOKEN" ]]; then + echo "ERROR: failed to fetch registration token from Gitea API. Is Gitea reachable?" + exit 3 + fi + + # Stop the runner service + systemctl --user stop gitea-runner.service 2>/dev/null || true + sleep 1 + + # Remove the stale .runner file + rm -f "{{ gitea_runner_data_dir }}/.runner" 2>/dev/null || true + + # Re-register + cd "{{ gitea_runner_data_dir }}" + if "$BINARY" register \ + --token "$REG_TOKEN" \ + --name "$RUNNER_NAME" \ + --instance "$GITEA_URL" \ + --labels "$RUNNER_LABELS" \ + --no-interactive 2>&1; then + echo "RECOVERED: runner re-registered successfully" + else + echo "ERROR: re-registration failed. Manual intervention required." + exit 3 + fi + + # Start the runner service + systemctl --user start gitea-runner.service + sleep 3 + + # Verify recovery — check if unregistered errors stopped + new_errors=$(journalctl --user -u gitea-runner.service --since "10 seconds ago" --no-pager -q 2>/dev/null | grep -c "unregistered runner" || true) + if [[ "$new_errors" -eq 0 ]]; then + echo "OK: runner recovered and no longer reporting unregistered errors" + # Clear cooldown on success + rm -f "$COOLDOWN_FILE" 2>/dev/null || true + else + echo "WARN: runner still showing unregistered errors after re-registration. Will retry after cooldown." + exit 3 + fi +{% else %} + echo "Manual re-registration required: re-run gitea_runner role with gitea_runner_force_reregister=true." + # Restart the service once in case it is a transient token refresh issue, + # but this cannot recover an unregistered runner without re-registration. + systemctl --user restart gitea-runner.service + sleep 2 + exit 3 +{% endif %} +fi + # 3. Check disk space — prune aggressively if below threshold disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}') if [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_threshold }} ]]; then diff --git a/ansible/start-runner.yml b/ansible/start-runner.yml index 9d4c00e..7f9147c 100644 --- a/ansible/start-runner.yml +++ b/ansible/start-runner.yml @@ -33,5 +33,5 @@ become_user: "{{ gitea_runner_service_user }}" environment: XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" - when: systemd_available.stat.exists + when: gitea_runner_systemd_available.stat.exists changed_when: true diff --git a/pyproject.toml b/pyproject.toml index f3184bd..44673bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ ci = [ "build==1.5.1", "twine==6.2.0", # Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.) - "devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.8", + "devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.10", ] # Lint and type-checking tools (validate job) lint = [ @@ -56,7 +56,7 @@ molecule = [ dev = [ "grm[ci,lint,molecule]", # Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr) - "devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.8", + "devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.10", # Non-Python dev dependency: checkmake (Makefile linter) # Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest ] diff --git a/src/grm/cli.py b/src/grm/cli.py index 67120d3..ecb750f 100644 --- a/src/grm/cli.py +++ b/src/grm/cli.py @@ -145,11 +145,25 @@ def cli(ctx: click.Context, become_password_file: str | None, verbose: bool) -> "Example: docker:docker://alpine:latest" ), ) +@click.option( + "--force-reregister/--no-force-reregister", + default=False, + help=_("Force re-registration even if .runner file exists (env: GITEA_FORCE_REREGISTER)"), +) @click.option( "--ask-become-pass/--no-ask-become-pass", default=True, help=_("Prompt for sudo password (default)"), ) +@click.option( + "--auto-recover-token", + default=None, + help=_( + "Gitea API token for healthcheck auto-recovery (env: GITEA_AUTO_RECOVER_TOKEN). " + "When set, the healthcheck can automatically re-register the runner " + "if it becomes unregistered. Requires admin or org-level access." + ), +) @_handle_errors("Installation failed: {error}") def install( host: str, @@ -161,10 +175,14 @@ def install( admin_token: str | None, integration_retries: int, labels: str | None, + force_reregister: bool, ask_become_pass: bool, + auto_recover_token: str | None, ) -> None: if labels is None: labels = os.getenv("GITEA_RUNNER_LABELS") + if auto_recover_token is None: + auto_recover_token = os.getenv("GITEA_AUTO_RECOVER_TOKEN") manager = RunnerManager() manager.install( host=host, @@ -176,9 +194,11 @@ def install( admin_token=admin_token, integration_retries=integration_retries, labels=labels, + force_reregister=force_reregister, ask_become_pass=ask_become_pass, become_password_file=_get_become_password_file(), verbose=_get_verbose(), + auto_recover_token=auto_recover_token, ) @@ -506,7 +526,7 @@ def list_runners(ask_become_pass: bool, no_status: bool) -> None: 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']}") + click.echo(f"{r['name']:<18} {r['host']:<16} {r['user']:<10} {(r['labels'] or ''):<30} {r['status']}") @cli.command(name="trigger-workflow", help=_("Trigger a Gitea Actions workflow via the API.")) diff --git a/src/grm/runner_manager.py b/src/grm/runner_manager.py index 6542c91..5eed2a2 100644 --- a/src/grm/runner_manager.py +++ b/src/grm/runner_manager.py @@ -87,8 +87,10 @@ class RunnerManager: integration_retries: int = 3, ask_become_pass: bool = False, labels: str | None = None, + force_reregister: bool = False, become_password_file: str | None = None, verbose: bool = False, + auto_recover_token: str | None = None, ) -> None: """Install a runner on a remote host using Ansible.""" if not name: @@ -98,16 +100,19 @@ class RunnerManager: if not token: raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)")) - extra_vars: dict[str, str | int] = { + extra_vars: dict[str, str | int | bool] = { "registration_token": token, "gitea_runner_name": name, "gitea_url": gitea_url, "gitea_runner_integration_retries": integration_retries, + "gitea_runner_force_reregister": force_reregister, } if admin_token: extra_vars["gitea_admin_token"] = admin_token if labels is not None: extra_vars["gitea_runner_labels"] = labels + if auto_recover_token: + extra_vars["gitea_runner_auto_recover_api_token"] = auto_recover_token with track_steps() as tracker: tracker.begin(_("Installing Gitea Runner on {host}", host=host)) diff --git a/src/grm/translations.json b/src/grm/translations.json index c7555b9..48f8660 100644 --- a/src/grm/translations.json +++ b/src/grm/translations.json @@ -367,6 +367,14 @@ "ru": "Токен регистрации (env: GITEA_REGISTRATION_TOKEN)", "zh": "注册令牌(环境变量: GITEA_REGISTRATION_TOKEN)" }, + "Force re-registration even if .runner file exists (env: GITEA_FORCE_REREGISTER)": { + "bg": "Принудителна повторна регистрация, дори ако .runner файлът съществува (env: GITEA_FORCE_REREGISTER)", + "de": "Erneute Registrierung erzwingen, auch wenn .runner-Datei existiert (env: GITEA_FORCE_REREGISTER)", + "en": "Force re-registration even if .runner file exists (env: GITEA_FORCE_REREGISTER)", + "pl": "Wymuś ponowną rejestrację, nawet jeśli plik .runner istnieje (env: GITEA_FORCE_REREGISTER)", + "ru": "Принудительно повторно зарегистрировать, даже если файл .runner существует (env: GITEA_FORCE_REREGISTER)", + "zh": "即使存在 .runner 文件也强制重新注册(环境变量: GITEA_FORCE_REREGISTER)" + }, "Remove a registered Gitea Runner completely.": { "bg": "Пълно премахване на регистриран Gitea Runner.", "de": "Einen registrierten Gitea Runner vollständig entfernen.", @@ -774,5 +782,13 @@ "pl": "Workflow uruchomiony pomyślnie. ID uruchomienia: {run_id}", "ru": "Workflow успешно запущен. ID запуска: {run_id}", "zh": "工作流触发成功。运行 ID:{run_id}" + }, + "Gitea API token for healthcheck auto-recovery (env: GITEA_AUTO_RECOVER_TOKEN). When set, the healthcheck can automatically re-register the runner if it becomes unregistered. Requires admin or org-level access.": { + "bg": "Gitea API token for healthcheck auto-recovery (env: GITEA_AUTO_RECOVER_TOKEN). When set, the healthcheck can automatically re-register the runner if it becomes unregistered. Requires admin or org-level access.", + "de": "Gitea API token for healthcheck auto-recovery (env: GITEA_AUTO_RECOVER_TOKEN). When set, the healthcheck can automatically re-register the runner if it becomes unregistered. Requires admin or org-level access.", + "en": "Gitea API token for healthcheck auto-recovery (env: GITEA_AUTO_RECOVER_TOKEN). When set, the healthcheck can automatically re-register the runner if it becomes unregistered. Requires admin or org-level access.", + "pl": "Gitea API token for healthcheck auto-recovery (env: GITEA_AUTO_RECOVER_TOKEN). When set, the healthcheck can automatically re-register the runner if it becomes unregistered. Requires admin or org-level access.", + "ru": "Gitea API token for healthcheck auto-recovery (env: GITEA_AUTO_RECOVER_TOKEN). When set, the healthcheck can automatically re-register the runner if it becomes unregistered. Requires admin or org-level access.", + "zh": "Gitea API token for healthcheck auto-recovery (env: GITEA_AUTO_RECOVER_TOKEN). When set, the healthcheck can automatically re-register the runner if it becomes unregistered. Requires admin or org-level access." } } diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 840b938..b63fa6a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -50,9 +50,11 @@ class TestCLI: admin_token="", integration_retries=3, labels=None, + force_reregister=False, ask_become_pass=True, become_password_file=None, verbose=False, + auto_recover_token=None, ) @patch("grm.cli.RunnerManager") @@ -73,9 +75,11 @@ class TestCLI: admin_token="", integration_retries=3, labels=None, + force_reregister=False, ask_become_pass=False, become_password_file=None, verbose=False, + auto_recover_token=None, ) @patch("grm.cli.RunnerManager") @@ -114,9 +118,11 @@ class TestCLI: admin_token=None, integration_retries=3, labels=None, + force_reregister=False, ask_become_pass=True, become_password_file=None, verbose=False, + auto_recover_token=None, ) @patch("grm.cli.RunnerManager") @@ -165,9 +171,11 @@ class TestCLI: admin_token="", integration_retries=3, labels=None, + force_reregister=False, ask_become_pass=True, become_password_file=None, verbose=False, + auto_recover_token=None, ) @patch("grm.cli.RunnerManager") @@ -188,9 +196,11 @@ class TestCLI: admin_token="", integration_retries=3, labels=None, + force_reregister=False, ask_become_pass=True, become_password_file=None, verbose=False, + auto_recover_token=None, ) @patch("grm.cli.RunnerManager") @@ -226,9 +236,92 @@ class TestCLI: admin_token="", integration_retries=3, labels="docker:docker://alpine:latest", + force_reregister=False, ask_become_pass=True, become_password_file=None, verbose=False, + auto_recover_token=None, + ) + + @patch("grm.cli.RunnerManager") + def test_install_force_reregister(self, mock_manager_class: MagicMock) -> None: + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + runner = CliRunner(env=_TEST_ENV) + result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--force-reregister"]) + assert result.exit_code == 0 + mock_manager.install.assert_called_once_with( + host="host1", + user="ubuntu", + key=None, + name=None, + token="tok", + gitea_url="https://git.example.com", + admin_token="", + integration_retries=3, + labels=None, + force_reregister=True, + ask_become_pass=True, + become_password_file=None, + verbose=False, + auto_recover_token=None, + ) + + @patch("grm.cli.RunnerManager") + def test_install_with_auto_recover_token(self, mock_manager_class: MagicMock) -> None: + """--auto-recover-token passes the token to the manager for healthcheck auto-recovery.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + runner = CliRunner(env=_TEST_ENV) + result = runner.invoke( + cli, + ["install", "host1", "--user", "ubuntu", "--token", "tok", "--auto-recover-token", "api-tok"], + ) + assert result.exit_code == 0 + mock_manager.install.assert_called_once_with( + host="host1", + user="ubuntu", + key=None, + name=None, + token="tok", + gitea_url="https://git.example.com", + admin_token="", + integration_retries=3, + labels=None, + force_reregister=False, + ask_become_pass=True, + become_password_file=None, + verbose=False, + auto_recover_token="api-tok", + ) + + @patch("grm.cli.RunnerManager") + def test_install_auto_recover_token_from_env(self, mock_manager_class: MagicMock) -> None: + """GITEA_AUTO_RECOVER_TOKEN env var is used when --auto-recover-token is not passed.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + env = {**_TEST_ENV, "GITEA_AUTO_RECOVER_TOKEN": "env-tok"} + runner = CliRunner(env=env) + result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok"]) + assert result.exit_code == 0 + mock_manager.install.assert_called_once_with( + host="host1", + user="ubuntu", + key=None, + name=None, + token="tok", + gitea_url="https://git.example.com", + admin_token="", + integration_retries=3, + labels=None, + force_reregister=False, + ask_become_pass=True, + become_password_file=None, + verbose=False, + auto_recover_token="env-tok", ) @patch("grm.cli.RunnerManager") @@ -250,9 +343,11 @@ class TestCLI: admin_token="", integration_retries=3, labels="", + force_reregister=False, ask_become_pass=True, become_password_file=None, verbose=False, + auto_recover_token=None, ) @patch("grm.cli.RunnerManager") @@ -274,9 +369,11 @@ class TestCLI: admin_token="", integration_retries=3, labels="docker:docker://alpine:latest", + force_reregister=False, ask_become_pass=True, become_password_file=None, verbose=False, + auto_recover_token=None, ) @patch("grm.cli.RunnerManager") @@ -307,9 +404,11 @@ class TestCLI: admin_token="", integration_retries=3, labels=None, + force_reregister=False, ask_become_pass=True, become_password_file=pw_file, verbose=False, + auto_recover_token=None, ) finally: import os @@ -334,9 +433,11 @@ class TestCLI: admin_token="", integration_retries=3, labels=None, + force_reregister=False, ask_become_pass=True, become_password_file=None, verbose=True, + auto_recover_token=None, ) @patch("grm.cli.RunnerManager") @@ -741,6 +842,27 @@ class TestCLI: assert "active" in result.output mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=False) + @patch("grm.cli.RunnerManager") + def test_list_with_none_labels(self, mock_manager_class: MagicMock) -> None: + """Runners with labels=None should not crash the list command.""" + mock_manager = MagicMock() + mock_manager.list_runners.return_value = [ + { + "name": "r1", + "host": "10.0.0.1", + "user": "ubuntu", + "labels": None, + "status": "active", + }, + ] + mock_manager_class.return_value = mock_manager + + runner = CliRunner() + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0 + assert "r1" in result.output + assert "active" in result.output + @patch("grm.cli.RunnerManager") def test_list_no_status(self, mock_manager_class: MagicMock) -> None: """--no-status skips SSH checks and shows registry only.""" diff --git a/tests/unit/test_runner_manager.py b/tests/unit/test_runner_manager.py index 9febca3..8ba6efa 100644 --- a/tests/unit/test_runner_manager.py +++ b/tests/unit/test_runner_manager.py @@ -51,6 +51,7 @@ class TestRunnerManager: assert manager._captured_extra_vars["registration_token"] == "tok" assert manager._captured_extra_vars["gitea_runner_name"] == "192.168.1.10" assert manager._captured_extra_vars["gitea_url"] == "https://git.example.com" + assert manager._captured_extra_vars["gitea_runner_force_reregister"] is False assert "Installing Gitea Runner on 192.168.1.10" in mock_executor.run.call_args.kwargs["description"] mock_registry.add.assert_called_once_with( name="192.168.1.10", @@ -76,6 +77,7 @@ class TestRunnerManager: assert "/key" in cmd_str assert manager._captured_extra_vars["registration_token"] == "preset" assert manager._captured_extra_vars["gitea_runner_name"] == "my-runner" + assert manager._captured_extra_vars["gitea_runner_force_reregister"] is False assert "--ask-become-pass" not in cmd_str mock_registry.add.assert_called_once_with( name="my-runner", @@ -97,6 +99,52 @@ class TestRunnerManager: cmd_str = " ".join(cmd) assert "--ask-become-pass" in cmd_str + def test_install_force_reregister(self) -> None: + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) + mock_executor = MagicMock() + manager._executor = mock_executor + + manager.install( + "host1", + "root", + token="tok", + gitea_url="https://git.example.com", + force_reregister=True, + ) + assert manager._captured_extra_vars["gitea_runner_force_reregister"] is True + + def test_install_with_auto_recover_token(self) -> None: + """auto_recover_token is passed as extra_var to Ansible.""" + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) + mock_executor = MagicMock() + manager._executor = mock_executor + + manager.install( + "host1", + "root", + token="tok", + gitea_url="https://git.example.com", + auto_recover_token="api-tok", + ) + assert manager._captured_extra_vars["gitea_runner_auto_recover_api_token"] == "api-tok" + + def test_install_without_auto_recover_token(self) -> None: + """When auto_recover_token is None, the extra_var is not set.""" + mock_registry = MagicMock() + manager = RunnerManager(registry=mock_registry) + mock_executor = MagicMock() + manager._executor = mock_executor + + manager.install( + "host1", + "root", + token="tok", + gitea_url="https://git.example.com", + ) + assert "gitea_runner_auto_recover_api_token" not in manager._captured_extra_vars + def test_install_missing_gitea_url(self) -> None: manager = RunnerManager() with pytest.raises(AnsibleError, match="GITEA_URL must be set"):