diff --git a/.vale/styles/Google/Anthropomorphism.yml b/.vale/styles/Google/Anthropomorphism.yml new file mode 100644 index 0000000..36137a1 --- /dev/null +++ b/.vale/styles/Google/Anthropomorphism.yml @@ -0,0 +1,12 @@ +extends: existence +message: "Don't attribute human qualities to software or hardware ('%s')." +link: https://developers.google.com/style/anthropomorphism +level: suggestion +ignorecase: true +# Limited to the two verbs the guide itself names. Broader lists (wants, knows, +# thinks) can't tell a software subject from a human one: on a 950-file corpus +# they produced 8 false positives ('the customer wants', 'your audience knows') +# for every 2 real ones. +tokens: + - sees + - tells diff --git a/.vale/styles/Google/Colons.yml b/.vale/styles/Google/Colons.yml index 4a027c3..98972b9 100644 --- a/.vale/styles/Google/Colons.yml +++ b/.vale/styles/Google/Colons.yml @@ -1,8 +1,13 @@ extends: existence message: "'%s' should be in lowercase." link: 'https://developers.google.com/style/colons' -nonword: true level: warning scope: sentence +# The match is the word itself, not ': X', and `nonword` is off. Both are +# required for a project Vocab to work: Vale compares accept.txt entries +# against the matched text, and `nonword: true` opts out of that entirely. +# So a proper noun after a colon can be exempted by adding it to accept.txt. +# The guide's other exemption, notice labels, is handled by the lookbehinds; +# headings are already excluded by `scope: sentence`. See issue #20. tokens: - - '(?- + Healthcheck script uses 'prune -af' which removes ALL images + (including tagged runner images like ci-full). Use 'prune -f' + (dangling only) to preserve tagged images. diff --git a/ansible/roles/gitea_runner/tasks/deregister.yml b/ansible/roles/gitea_runner/tasks/deregister.yml index 517eabe..26ff643 100644 --- a/ansible/roles/gitea_runner/tasks/deregister.yml +++ b/ansible/roles/gitea_runner/tasks/deregister.yml @@ -29,6 +29,7 @@ become_user: "{{ gitea_runner_service_user }}" environment: 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: - gitea_runner_file_stat.stat.exists | default(false) | bool diff --git a/ansible/roles/gitea_runner/tasks/healthcheck.yml b/ansible/roles/gitea_runner/tasks/healthcheck.yml index 1189437..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 @@ -29,6 +45,7 @@ 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 @@ -40,6 +57,7 @@ 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 diff --git a/ansible/roles/gitea_runner/tasks/integration_test.yml b/ansible/roles/gitea_runner/tasks/integration_test.yml index b0e65c3..cdadd8c 100644 --- a/ansible/roles/gitea_runner/tasks/integration_test.yml +++ b/ansible/roles/gitea_runner/tasks/integration_test.yml @@ -18,14 +18,18 @@ else {} }} when: gitea_runner_file_stat.stat.exists | default(false) | bool -- name: Verify runner user service active +- name: Wait for runner user service to be active ansible.builtin.command: systemctl --user is-active 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" register: gitea_runner_service_check changed_when: false + retries: 10 + delay: 2 + until: gitea_runner_service_check.stdout | default('') | trim == 'active' when: - gitea_runner_systemd_available.stat.exists - gitea_runner_docker_rootless_setup diff --git a/ansible/roles/gitea_runner/tasks/main.yml b/ansible/roles/gitea_runner/tasks/main.yml index 4973dd1..d887d03 100644 --- a/ansible/roles/gitea_runner/tasks/main.yml +++ b/ansible/roles/gitea_runner/tasks/main.yml @@ -17,6 +17,9 @@ - name: Include healthcheck setup ansible.builtin.include_tasks: healthcheck.yml +- name: Include pre-pull images + ansible.builtin.include_tasks: pre_pull_images.yml + - name: Include integration test ansible.builtin.include_tasks: integration_test.yml when: not gitea_runner_skip_registration diff --git a/ansible/roles/gitea_runner/tasks/pre_pull_images.yml b/ansible/roles/gitea_runner/tasks/pre_pull_images.yml new file mode 100644 index 0000000..ed0f265 --- /dev/null +++ b/ansible/roles/gitea_runner/tasks/pre_pull_images.yml @@ -0,0 +1,28 @@ +--- +# Pre-pull Docker images that CI runners need to avoid pulling them on +# every CI run. The runner container image (ci-full) is large (~3.3GB) +# and pulling it on every run causes timeouts and disk pressure. +# +# The healthcheck script's disk-space prune only removes dangling images +# (not tagged ones), so pre-pulled images persist between CI runs. +# +# Set gitea_runner_pre_pull_images to a list of image refs to pull, or +# empty list to skip pre-pulling. + +- name: Pre-pull Docker images for CI runner + ansible.builtin.command: "docker pull {{ item }}" + become: true + become_user: "{{ gitea_runner_service_user }}" + environment: + DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock" + XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus" + register: gitea_runner_pre_pull_result + changed_when: "'Status: Downloaded' in gitea_runner_pre_pull_result.stdout or 'Status: Downloaded' in gitea_runner_pre_pull_result.stderr" + retries: 3 + delay: 5 + until: gitea_runner_pre_pull_result is success + loop: "{{ gitea_runner_pre_pull_images }}" + when: + - gitea_runner_docker_rootless_setup + - gitea_runner_pre_pull_images | length > 0 diff --git a/ansible/roles/gitea_runner/tasks/prune.yml b/ansible/roles/gitea_runner/tasks/prune.yml index 3494632..245b17c 100644 --- a/ansible/roles/gitea_runner/tasks/prune.yml +++ b/ansible/roles/gitea_runner/tasks/prune.yml @@ -6,6 +6,7 @@ owner: "{{ gitea_runner_service_user }}" group: "{{ gitea_runner_service_user }}" mode: "0644" + register: gitea_runner_prune_service - name: Create docker-prune user timer file ansible.builtin.template: @@ -14,6 +15,7 @@ owner: "{{ gitea_runner_service_user }}" group: "{{ gitea_runner_service_user }}" mode: "0644" + register: gitea_runner_prune_timer - name: Reload systemd user daemon for prune timer ansible.builtin.command: systemctl --user daemon-reload @@ -21,10 +23,12 @@ 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_prune_service is changed or gitea_runner_prune_timer is changed - name: Enable and start docker-prune user timer ansible.builtin.command: systemctl --user enable --now docker-prune.timer @@ -32,6 +36,7 @@ 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 diff --git a/ansible/roles/gitea_runner/tasks/register.yml b/ansible/roles/gitea_runner/tasks/register.yml index fd8c7d1..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 @@ -26,8 +35,25 @@ become_user: "{{ gitea_runner_service_user }}" environment: 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 ad17a6c..8f087dc 100644 --- a/ansible/roles/gitea_runner/tasks/rootless_docker.yml +++ b/ansible/roles/gitea_runner/tasks/rootless_docker.yml @@ -31,6 +31,11 @@ - ansible_facts['os_family'] == 'Debian' - gitea_runner_docker_apt_repo is changed +# Install Docker packages from the upstream Docker APT repository. +# We do NOT pin to 28.x because recent Ubuntu releases (e.g. 26.04/plucky) +# may not have 28.x packages in the Docker repo, and Docker 29 is safe +# for rootless mode when the daemon.json disables the containerd snapshotter +# and sets a conservative default nofile ulimit (see daemon.json tasks below). - name: Install rootless Docker dependencies (Debian/Ubuntu) ansible.builtin.apt: name: @@ -45,6 +50,7 @@ - docker-compose-plugin - rsync state: present + register: gitea_runner_docker_install when: ansible_facts['os_family'] == 'Debian' - name: Update pacman cache (Arch Linux) @@ -133,8 +139,57 @@ content: | [Service] Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET={{ gitea_runner_docker_rootless_net_driver }}" - Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit" + Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER={{ 'implicit' if gitea_runner_docker_rootless_net_driver == 'pasta' else 'builtin' }}" + {% if gitea_runner_docker_rootless_net_driver == 'pasta' %} Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6" + {% endif %} + mode: "0644" + owner: "{{ gitea_runner_service_user }}" + group: "{{ gitea_runner_service_user }}" + when: + - gitea_runner_docker_rootless_setup + - not gitea_runner_rootless_docker_check.stat.exists + +# Write daemon.json BEFORE the setuptool starts dockerd, so Docker 29 +# starts with containerd snapshotter disabled from the very first boot. +# Without this, Docker 29 uses containerd snapshots by default, which +# causes instability in rootless mode. +- name: Ensure Docker config directory exists (pre-setup) + ansible.builtin.file: + path: "{{ gitea_runner_home }}/.config/docker" + state: directory + mode: "0755" + owner: "{{ gitea_runner_service_user }}" + group: "{{ gitea_runner_service_user }}" + when: + - gitea_runner_docker_rootless_setup + - not gitea_runner_rootless_docker_check.stat.exists + +- name: Pre-configure rootless Docker daemon.json (disable containerd snapshotter) + ansible.builtin.copy: + dest: "{{ gitea_runner_home }}/.config/docker/daemon.json" + content: | + { + "live-restore": {{ gitea_runner_docker_live_restore | to_json }}, + "shutdown-timeout": {{ gitea_runner_docker_shutdown_timeout }}, + "max-concurrent-downloads": {{ gitea_runner_docker_max_concurrent_downloads }}, + "max-concurrent-uploads": {{ gitea_runner_docker_max_concurrent_uploads }}, + "default-ulimits": { + "nofile": {"Name": "nofile", "Hard": {{ gitea_runner_docker_default_nofile }}, "Soft": {{ gitea_runner_docker_default_nofile }}} + }, + "features": { + "containerd-snapshotter": false + }, + {% if gitea_runner_docker_rootless_net_driver == 'pasta' %} + "ipv6": true, + "ip6tables": true, + "fixed-cidr-v6": "{{ gitea_runner_docker_ipv6_cidr }}", + "dns": ["10.0.2.3", "8.8.8.8"] + {% else %} + "ipv6": false, + "dns": ["8.8.8.8", "1.1.1.1"] + {% endif %} + } mode: "0644" owner: "{{ gitea_runner_service_user }}" group: "{{ gitea_runner_service_user }}" @@ -150,6 +205,7 @@ 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 }}/bus" DOCKERD_ROOTLESS_ROOTLESSKIT_NET: "{{ gitea_runner_docker_rootless_net_driver }}" when: - gitea_runner_docker_rootless_setup @@ -161,6 +217,7 @@ 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 }}/bus" changed_when: true when: gitea_runner_docker_rootless_setup @@ -170,6 +227,7 @@ 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 }}/bus" changed_when: true when: gitea_runner_docker_rootless_setup @@ -182,14 +240,16 @@ group: "{{ gitea_runner_service_user }}" when: gitea_runner_docker_rootless_setup -- name: Configure rootless Docker to use pasta with IPv6 +- name: Configure rootless Docker network driver ansible.builtin.copy: dest: "{{ gitea_runner_home }}/.config/systemd/user/docker.service.d/override.conf" content: | [Service] Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET={{ gitea_runner_docker_rootless_net_driver }}" - Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit" + Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER={{ 'implicit' if gitea_runner_docker_rootless_net_driver == 'pasta' else 'builtin' }}" + {% if gitea_runner_docker_rootless_net_driver == 'pasta' %} Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6" + {% endif %} mode: "0644" owner: "{{ gitea_runner_service_user }}" group: "{{ gitea_runner_service_user }}" @@ -202,19 +262,36 @@ 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 }}/bus" changed_when: true when: - gitea_runner_docker_rootless_setup - gitea_runner_docker_network_override is changed -- name: Configure rootless Docker daemon with IPv6 enabled +- name: Configure rootless Docker daemon ansible.builtin.copy: dest: "{{ gitea_runner_home }}/.config/docker/daemon.json" content: | { + "live-restore": {{ gitea_runner_docker_live_restore | to_json }}, + "shutdown-timeout": {{ gitea_runner_docker_shutdown_timeout }}, + "max-concurrent-downloads": {{ gitea_runner_docker_max_concurrent_downloads }}, + "max-concurrent-uploads": {{ gitea_runner_docker_max_concurrent_uploads }}, + "default-ulimits": { + "nofile": {"Name": "nofile", "Hard": {{ gitea_runner_docker_default_nofile }}, "Soft": {{ gitea_runner_docker_default_nofile }}} + }, + "features": { + "containerd-snapshotter": false + }, + {% if gitea_runner_docker_rootless_net_driver == 'pasta' %} "ipv6": true, "ip6tables": true, - "fixed-cidr-v6": "{{ gitea_runner_docker_ipv6_cidr }}" + "fixed-cidr-v6": "{{ gitea_runner_docker_ipv6_cidr }}", + "dns": ["10.0.2.3", "8.8.8.8"] + {% else %} + "ipv6": false, + "dns": ["8.8.8.8", "1.1.1.1"] + {% endif %} } mode: "0644" owner: "{{ gitea_runner_service_user }}" @@ -228,6 +305,7 @@ 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 }}/bus" changed_when: true when: - gitea_runner_docker_rootless_setup @@ -240,6 +318,7 @@ environment: DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus" register: gitea_runner_docker_ready until: gitea_runner_docker_ready.rc == 0 retries: 10 diff --git a/ansible/roles/gitea_runner/tasks/service.yml b/ansible/roles/gitea_runner/tasks/service.yml index 8b63486..bc18c8a 100644 --- a/ansible/roles/gitea_runner/tasks/service.yml +++ b/ansible/roles/gitea_runner/tasks/service.yml @@ -6,6 +6,7 @@ owner: "{{ gitea_runner_service_user }}" group: "{{ gitea_runner_service_user }}" mode: "0644" + register: gitea_runner_service_file - name: Reload systemd user daemon ansible.builtin.command: systemctl --user daemon-reload @@ -13,10 +14,25 @@ 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_service_file is changed + +- name: Restart gitea-runner if service file changed + ansible.builtin.command: systemctl --user restart 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_service_file is changed - name: Enable and start gitea-runner user service ansible.builtin.command: systemctl --user enable --now gitea-runner @@ -24,6 +40,7 @@ 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 diff --git a/ansible/roles/gitea_runner/tasks/update_runner.yml b/ansible/roles/gitea_runner/tasks/update_runner.yml index 5b306e7..dbd10f7 100644 --- a/ansible/roles/gitea_runner/tasks/update_runner.yml +++ b/ansible/roles/gitea_runner/tasks/update_runner.yml @@ -8,6 +8,7 @@ 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" when: - gitea_runner_systemd_available.stat.exists | default(false) | bool - gitea_runner_docker_rootless_setup diff --git a/ansible/roles/gitea_runner/tasks/validate.yml b/ansible/roles/gitea_runner/tasks/validate.yml index 7a3327b..d6ba1f2 100644 --- a/ansible/roles/gitea_runner/tasks/validate.yml +++ b/ansible/roles/gitea_runner/tasks/validate.yml @@ -21,6 +21,7 @@ environment: DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus" register: gitea_runner_docker_version_output changed_when: false when: gitea_runner_docker_rootless_setup diff --git a/ansible/roles/gitea_runner/templates/docker-prune.service.j2 b/ansible/roles/gitea_runner/templates/docker-prune.service.j2 index d0ad2b4..5a3f1e3 100644 --- a/ansible/roles/gitea_runner/templates/docker-prune.service.j2 +++ b/ansible/roles/gitea_runner/templates/docker-prune.service.j2 @@ -7,3 +7,6 @@ Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }} ExecStart=/usr/bin/docker system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}" ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}" +ExecStart=/usr/bin/docker container prune -f +ExecStart=/usr/bin/docker network prune -f +ExecStart=/usr/bin/docker builder prune -f diff --git a/ansible/roles/gitea_runner/templates/gitea-runner-user.service.j2 b/ansible/roles/gitea_runner/templates/gitea-runner-user.service.j2 index b130809..45373ba 100644 --- a/ansible/roles/gitea_runner/templates/gitea-runner-user.service.j2 +++ b/ansible/roles/gitea_runner/templates/gitea-runner-user.service.j2 @@ -3,6 +3,8 @@ Description=Gitea Actions Runner (rootless) After=docker.service Requires=docker.service PartOf=docker.service +StartLimitIntervalSec=300 +StartLimitBurst=10 [Service] Type=simple @@ -14,8 +16,6 @@ ExecStop=/bin/kill -TERM $MAINPID TimeoutStopSec=30 Restart=always RestartSec={{ gitea_runner_service_restart_sec }} -StartLimitIntervalSec=300 -StartLimitBurst=10 [Install] WantedBy=default.target diff --git a/ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2 b/ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2 index da2a3f4..2bc7b12 100644 --- a/ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2 +++ b/ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2 @@ -7,18 +7,29 @@ DOCKER_HOST="unix:///run/user/{{ gitea_runner_uid }}/docker.sock" XDG_RUNTIME_DIR="/run/user/{{ gitea_runner_uid }}" export DOCKER_HOST XDG_RUNTIME_DIR -# 1. Check Docker daemon responsiveness -if ! docker info >/dev/null 2>&1; then - echo "ERROR: Docker daemon not responding at ${DOCKER_HOST}" +# 1. Check Docker daemon responsiveness (with timeout — a bare `docker info` can +# hang indefinitely on a stuck daemon, blocking the healthcheck itself). +if ! timeout 10 docker info >/dev/null 2>&1; then + echo "ERROR: Docker daemon not responding at ${DOCKER_HOST} (timed out after 10s)" systemctl --user restart docker.service sleep 3 - if ! docker info >/dev/null 2>&1; then + if ! timeout 10 docker info >/dev/null 2>&1; then echo "CRITICAL: Docker daemon still down after restart" exit 1 fi echo "RECOVERED: Docker daemon restarted successfully" fi +# 1b. Clean up stuck containers — containers in "removing" or "stopping" state +# for too long cause "cannot kill container: did not receive an exit event" +# errors in molecule destroy phases. Force-remove them so subsequent CI jobs +# don't inherit the stuck state. +stuck_containers=$(timeout 10 docker ps -a --filter "status=removing" --filter "status=stopping" --format '{% raw %}{{.ID}}{% endraw %}' 2>/dev/null || true) +if [[ -n "$stuck_containers" ]]; then + echo "WARN: Found stuck containers (removing/stopping), force-cleaning" + echo "$stuck_containers" | xargs -r docker rm -f 2>/dev/null || true +fi + # 2. Check gitea-runner service is active runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true) if [[ "$runner_state" != "active" ]]; then @@ -33,14 +44,128 @@ 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 echo "WARN: Disk usage at ${disk_pct}%, pruning all runner resources" - docker system prune -af --filter "label={{ gitea_runner_prune_label }}" --filter "until=1h" || true - docker volume prune -af --filter "label={{ gitea_runner_prune_label }}" || true - # Also prune dangling images (no label) - docker image prune -af || true + docker system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until=1h" || true + docker volume prune -f --filter "label={{ gitea_runner_prune_label }}" || true + # Only prune dangling (untagged) images — keep tagged runner images (ci-full, ci-quality) + docker image prune -f || true + # Clean up stopped containers and dangling networks that accumulate from failed jobs + docker container prune -f || true + docker network prune -f || true disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}') echo "INFO: Disk usage after prune: ${disk_pct}%" fi 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/docs/index.md b/docs/index.md index f7167e2..4bea547 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE) -[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/69e62973f488eb61d5d32b9454037f75b9b2bcb7/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) -[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/69e62973f488eb61d5d32b9454037f75b9b2bcb7/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) -[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/69e62973f488eb61d5d32b9454037f75b9b2bcb7/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) -[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/69e62973f488eb61d5d32b9454037f75b9b2bcb7/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) -[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/69e62973f488eb61d5d32b9454037f75b9b2bcb7/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) -[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/69e62973f488eb61d5d32b9454037f75b9b2bcb7/python.svg)](https://www.python.org/downloads/) +[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/7b6c9f92334256c26b0b018e3a23968a30abdf64/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/7b6c9f92334256c26b0b018e3a23968a30abdf64/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/7b6c9f92334256c26b0b018e3a23968a30abdf64/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) +[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/7b6c9f92334256c26b0b018e3a23968a30abdf64/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) +[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/7b6c9f92334256c26b0b018e3a23968a30abdf64/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) +[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/7b6c9f92334256c26b0b018e3a23968a30abdf64/python.svg)](https://www.python.org/downloads/) ## Overview diff --git a/pyproject.toml b/pyproject.toml index 5b31e48..44673bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,10 +32,11 @@ version = {attr = "grm.__version__"} ci = [ "pytest==9.1.1", "pytest-cov==7.1.0", + "pytest-xdist==3.8.0", "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.2", + "devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.10", ] # Lint and type-checking tools (validate job) lint = [ @@ -55,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.2", + "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/__init__.py b/src/grm/__init__.py index 05854f2..1c2ab50 100644 --- a/src/grm/__init__.py +++ b/src/grm/__init__.py @@ -1,3 +1,3 @@ """Gitea Runner Manager — lean CLI for managing Gitea Actions runners.""" -__version__ = "0.18.2" +__version__ = "0.18.7" 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"):