Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9d033a40 | ||
|
|
63ef5cdbcf | ||
|
|
8fbe2d3f51 | ||
|
|
a9178714af | ||
|
|
6ffcc38181 | ||
|
|
e5b0e17ec3 | ||
|
|
dc5e1431b5 | ||
|
|
dfa8d77bfa | ||
|
|
a178b1b5d2 | ||
|
|
99529a57af | ||
|
|
0eb033419f | ||
|
|
df4b7f2a19 | ||
|
|
312a706d39 | ||
|
|
ea2f0cc600 | ||
|
|
bd8e13ee66 | ||
|
|
41fc36ff4a | ||
|
|
e585543e9d | ||
|
|
c62c35f5b6 | ||
|
|
4b900ce673 | ||
|
|
cae66e0743 | ||
|
|
0b3a76c550 |
+29
-1
@@ -30,6 +30,17 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
make pytest-cov
|
||||
- name: Documentation lint check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
pip install --upgrade devx \
|
||||
--index-url "https://${CI_GITEA_USERNAME}:${CI_GITEA_TOKEN}@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/" \
|
||||
--no-deps
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
- name: Translation completeness check
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
@@ -147,8 +158,10 @@ jobs:
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: true
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
runner-index: [1, 2, 3]
|
||||
runner-index: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
@@ -250,6 +263,21 @@ jobs:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci
|
||||
- name: Post approval review
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.pr_review \
|
||||
"$PR_NUMBER" \
|
||||
"$REPOSITORY" \
|
||||
--event APPROVE \
|
||||
--checklist-confirmed \
|
||||
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
|
||||
--body "Auto-approved: all CI checks passed (quality, molecule, pr-review)."
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
|
||||
@@ -2,6 +2,38 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.12.5] - 2026-06-30
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Right-size molecule-tests matrix to [1-6]
|
||||
- Cast disk threshold to string in template-content verify assertion
|
||||
|
||||
## [0.12.4] - 2026-06-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use hardcoded matrix array for Gitea 1.26 compatibility
|
||||
|
||||
## [0.12.3] - 2026-06-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix wiki link URLs, heading hierarchy, quote pip install vars
|
||||
- Improve runner service stability and deregistration
|
||||
|
||||
## [0.12.2] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Bump devx to 0.26.3 (latest with pinned deps)
|
||||
|
||||
## [0.12.1] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add approval step to auto-merge workflow using REVIEW_GITEA_TOKEN
|
||||
|
||||
## [0.12.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Contributing to GRM
|
||||
|
||||
For the full contributing guide, see the [Contributing wiki page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Contributing).
|
||||
|
||||
Thank you for contributing to Gitea Runner Manager (GRM)!
|
||||
|
||||
## Branch Naming
|
||||
|
||||
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why GRM?
|
||||
|
||||
|
||||
+1
-15
@@ -1,17 +1,3 @@
|
||||
# Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause | Solution |
|
||||
|---------|-------------|----------|
|
||||
| Pre-commit rejects commit message | Missing conventional format or GRM-N prefix present | Use `feat: description` format without `GRM-N:` |
|
||||
| `make molecule` fails with `runner_name is undefined` | Verify playbook missing variable | Fixed in Phase 1.1; ensure you're on latest master |
|
||||
| CI molecule job fails | Docker not available on runner host | Ensure Gitea runner host has Docker installed and running |
|
||||
| Auto-merge doesn't trigger | Label not exactly `ready-to-merge` or CI checks not all green | Verify label spelling; check CI status |
|
||||
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
||||
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
||||
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
||||
| `devx.tools.configure_repo` fails | CI_GITEA_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||
| `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
|
||||
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
|
||||
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
||||
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
||||
| Prune/service templates created even when `docker_rootless_setup: false` | Template tasks not guarded | Fixed: template creation now guarded by `docker_rootless_setup` |
|
||||
See the [Troubleshooting guide](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Troubleshooting) in the wiki.
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
|
||||
- name: Stop and disable healthcheck timer
|
||||
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Include deregistration
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
|
||||
@@ -79,6 +79,16 @@
|
||||
tasks_from: deregister.yml
|
||||
when: not skip_runner_registration | default(false)
|
||||
|
||||
- name: Stop and disable healthcheck timer
|
||||
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Remove docker-prune user service file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.service"
|
||||
@@ -91,6 +101,24 @@
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove healthcheck user service file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.service"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove healthcheck user timer file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.timer"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove healthcheck script
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}/healthcheck.sh"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove systemd user unit file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/gitea-runner.service"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
collections:
|
||||
- name: community.general
|
||||
version: ">=13.1.0"
|
||||
version: "==13.1.0"
|
||||
- name: ansible.posix
|
||||
version: ">=2.2.0"
|
||||
version: "==2.2.0"
|
||||
- name: community.docker
|
||||
version: ">=5.2.1"
|
||||
version: "==5.2.1"
|
||||
|
||||
@@ -24,6 +24,17 @@ gitea_runner_prune_label: "gitea-runner=true"
|
||||
# Service configuration
|
||||
gitea_runner_service_restart_sec: "5"
|
||||
|
||||
# Health check configuration
|
||||
gitea_runner_healthcheck_interval: "5min"
|
||||
gitea_runner_healthcheck_boot_delay: "2min"
|
||||
gitea_runner_healthcheck_disk_threshold: 85
|
||||
gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
|
||||
|
||||
# Admin token for runner deregistration via Gitea API.
|
||||
# If not set, falls back to registration_token (which likely lacks admin scope).
|
||||
# Set this to a token with admin scope to enable automatic runner cleanup on removal.
|
||||
gitea_admin_token: ""
|
||||
|
||||
# Removal defaults
|
||||
remove_systemd_template: true
|
||||
remove_runner_user: true
|
||||
|
||||
@@ -75,3 +75,42 @@
|
||||
that:
|
||||
- timer_stat.stat.exists
|
||||
fail_msg: "Docker prune timer is missing"
|
||||
|
||||
- name: Check healthcheck script exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_healthcheck_script_path }}"
|
||||
register: healthcheck_script_stat
|
||||
|
||||
- name: Assert healthcheck script exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- healthcheck_script_stat.stat.exists
|
||||
fail_msg: "Healthcheck script is missing"
|
||||
|
||||
- name: Assert healthcheck script is executable
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- healthcheck_script_stat.stat.mode == "0755"
|
||||
fail_msg: "Healthcheck script is not executable"
|
||||
|
||||
- name: Check healthcheck service exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
|
||||
register: healthcheck_service_stat
|
||||
|
||||
- name: Assert healthcheck service exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- healthcheck_service_stat.stat.exists
|
||||
fail_msg: "Healthcheck systemd service is missing"
|
||||
|
||||
- name: Check healthcheck timer exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
|
||||
register: healthcheck_timer_stat
|
||||
|
||||
- name: Assert healthcheck timer exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- healthcheck_timer_stat.stat.exists
|
||||
fail_msg: "Healthcheck systemd timer is missing"
|
||||
|
||||
@@ -89,6 +89,39 @@
|
||||
- not prune_timer_stat.stat.exists
|
||||
fail_msg: "docker-prune timer unit still exists after removal"
|
||||
|
||||
- name: Check healthcheck service unit is absent
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
|
||||
register: healthcheck_service_stat
|
||||
|
||||
- name: Assert healthcheck service unit is absent
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not healthcheck_service_stat.stat.exists
|
||||
fail_msg: "runner-healthcheck service unit still exists after removal"
|
||||
|
||||
- name: Check healthcheck timer unit is absent
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
|
||||
register: healthcheck_timer_stat
|
||||
|
||||
- name: Assert healthcheck timer unit is absent
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not healthcheck_timer_stat.stat.exists
|
||||
fail_msg: "runner-healthcheck timer unit still exists after removal"
|
||||
|
||||
- name: Check healthcheck script is absent
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
|
||||
register: healthcheck_script_stat
|
||||
|
||||
- name: Assert healthcheck script is absent
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not healthcheck_script_stat.stat.exists
|
||||
fail_msg: "healthcheck script still exists after removal"
|
||||
|
||||
- name: Check subuid entry is absent
|
||||
ansible.builtin.command: "grep -c '^{{ gitea_runner_service_user }}:' /etc/subuid"
|
||||
register: subuid_check
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
that:
|
||||
- "'Type=simple' in service_template.content | b64decode"
|
||||
- "'ExecStart={{ gitea_runner_binary_path }}' in service_template.content | b64decode"
|
||||
- "'Restart=on-failure' in service_template.content | b64decode"
|
||||
- "'Restart=always' in service_template.content | b64decode"
|
||||
- "'Requires=docker.service' in service_template.content | b64decode"
|
||||
- "'PartOf=docker.service' in service_template.content | b64decode"
|
||||
- "'StartLimitBurst=10' in service_template.content | b64decode"
|
||||
- "'DOCKER_HOST=unix:///run/user' in service_template.content | b64decode"
|
||||
- "'XDG_RUNTIME_DIR=/run/user' in service_template.content | b64decode"
|
||||
fail_msg: "User service template is missing expected directives"
|
||||
@@ -59,3 +62,45 @@
|
||||
- "'OnCalendar={{ gitea_runner_prune_schedule }}' in prune_timer.content | b64decode"
|
||||
- "'Persistent=true' in prune_timer.content | b64decode"
|
||||
fail_msg: "Prune timer template is missing expected directives"
|
||||
|
||||
- name: Read rendered healthcheck service template
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
|
||||
register: healthcheck_service
|
||||
|
||||
- name: Assert healthcheck service contains expected directives
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'Type=oneshot' in healthcheck_service.content | b64decode"
|
||||
- "'ExecStart={{ gitea_runner_healthcheck_script_path }}' in healthcheck_service.content | b64decode"
|
||||
- "'DOCKER_HOST=unix:///run/user/' in healthcheck_service.content | b64decode"
|
||||
- "'XDG_RUNTIME_DIR=/run/user/' in healthcheck_service.content | b64decode"
|
||||
fail_msg: "Healthcheck service template is missing expected directives"
|
||||
|
||||
- name: Read rendered healthcheck timer template
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
|
||||
register: healthcheck_timer
|
||||
|
||||
- name: Assert healthcheck timer contains expected directives
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'OnBootSec={{ gitea_runner_healthcheck_boot_delay }}' in healthcheck_timer.content | b64decode"
|
||||
- "'OnUnitActiveSec={{ gitea_runner_healthcheck_interval }}' in healthcheck_timer.content | b64decode"
|
||||
- "'Persistent=true' in healthcheck_timer.content | b64decode"
|
||||
fail_msg: "Healthcheck timer template is missing expected directives"
|
||||
|
||||
- name: Read rendered healthcheck script
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_healthcheck_script_path }}"
|
||||
register: healthcheck_script
|
||||
|
||||
- name: Assert healthcheck script contains expected content
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'docker info' in healthcheck_script.content | b64decode"
|
||||
- "'systemctl --user restart docker.service' in healthcheck_script.content | b64decode"
|
||||
- "'systemctl --user restart gitea-runner.service' in healthcheck_script.content | b64decode"
|
||||
- "'docker system prune' in healthcheck_script.content | b64decode"
|
||||
- "gitea_runner_healthcheck_disk_threshold | string in healthcheck_script.content | b64decode"
|
||||
fail_msg: "Healthcheck script template is missing expected content"
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
else {} }}
|
||||
when: runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Deregister runner with Gitea via CLI
|
||||
- name: Deregister runner from Gitea via API
|
||||
ansible.builtin.command: >
|
||||
{{ gitea_runner_binary_path }} delete
|
||||
--token {{ registration_token }}
|
||||
--name {{ runner_name }}
|
||||
--instance {{ gitea_url }}
|
||||
--no-interactive
|
||||
curl -sf --connect-timeout 5 --max-time 10 -X DELETE
|
||||
-H "Authorization: token {{ gitea_admin_token | default(registration_token) }}"
|
||||
"{{ gitea_url }}/api/v1/admin/actions/runners/{{ runner_reg.id }}"
|
||||
args:
|
||||
chdir: "{{ gitea_runner_data_dir }}"
|
||||
become: true
|
||||
@@ -35,10 +33,24 @@
|
||||
when:
|
||||
- runner_file_stat.stat.exists | default(false) | bool
|
||||
- not skip_runner_registration
|
||||
- runner_reg.id is defined
|
||||
register: deregister_output
|
||||
changed_when: deregister_output.rc == 0
|
||||
failed_when: false
|
||||
|
||||
- name: Warn if deregistration failed
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
WARNING: Runner deregistration from Gitea failed (rc={{ deregister_output.rc | default('N/A') }}).
|
||||
The runner entry may remain in Gitea's admin UI as offline.
|
||||
Use an admin token (gitea_admin_token var) to enable automatic cleanup,
|
||||
or remove it manually from {{ gitea_url }}/-/admin/actions/runners
|
||||
when:
|
||||
- runner_file_stat.stat.exists | default(false) | bool
|
||||
- not skip_runner_registration
|
||||
- deregister_output is defined
|
||||
- deregister_output.rc | default(1) != 0
|
||||
|
||||
- name: Remove runner registration file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
- name: Create healthcheck script
|
||||
ansible.builtin.template:
|
||||
src: runner-healthcheck.sh.j2
|
||||
dest: "{{ gitea_runner_healthcheck_script_path }}"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: Create healthcheck user service file
|
||||
ansible.builtin.template:
|
||||
src: runner-healthcheck.service.j2
|
||||
dest: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.service"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Create healthcheck user timer file
|
||||
ansible.builtin.template:
|
||||
src: runner-healthcheck.timer.j2
|
||||
dest: "{{ gitea_runner_home }}/.config/systemd/user/runner-healthcheck.timer"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Reload systemd user daemon for healthcheck timer
|
||||
ansible.builtin.command: systemctl --user daemon-reload
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
|
||||
- name: Enable and start healthcheck user timer
|
||||
ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
@@ -14,6 +14,9 @@
|
||||
- name: Include prune setup
|
||||
ansible.builtin.include_tasks: prune.yml
|
||||
|
||||
- name: Include healthcheck setup
|
||||
ansible.builtin.include_tasks: healthcheck.yml
|
||||
|
||||
- name: Include integration test
|
||||
ansible.builtin.include_tasks: integration_test.yml
|
||||
when: not skip_runner_registration
|
||||
|
||||
@@ -6,4 +6,4 @@ Type=oneshot
|
||||
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 }}" --filter "until={{ gitea_runner_prune_until }}"
|
||||
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner (rootless)
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
PartOf=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -10,8 +12,10 @@ Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
|
||||
ExecStop=/bin/kill -TERM $MAINPID
|
||||
TimeoutStopSec=30
|
||||
Restart=on-failure
|
||||
Restart=always
|
||||
RestartSec={{ gitea_runner_service_restart_sec }}
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Gitea Runner health check (Docker + service + disk)
|
||||
After=docker.service gitea-runner.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
|
||||
ExecStart={{ gitea_runner_healthcheck_script_path }}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Health check for gitea-runner: verifies Docker daemon and runner service.
|
||||
# Exits 0 if healthy, 1 if Docker is down (triggers restart), 2 if runner is down.
|
||||
set -euo pipefail
|
||||
|
||||
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}"
|
||||
systemctl --user restart docker.service
|
||||
sleep 3
|
||||
if ! 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
|
||||
|
||||
# 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
|
||||
echo "ERROR: gitea-runner service is ${runner_state}, restarting"
|
||||
systemctl --user restart gitea-runner.service
|
||||
sleep 2
|
||||
runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true)
|
||||
if [[ "$runner_state" != "active" ]]; then
|
||||
echo "CRITICAL: gitea-runner service still down after restart"
|
||||
exit 2
|
||||
fi
|
||||
echo "RECOVERED: gitea-runner service restarted successfully"
|
||||
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
|
||||
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
|
||||
echo "INFO: Disk usage after prune: ${disk_pct}%"
|
||||
fi
|
||||
|
||||
echo "OK: runner healthy, disk at ${disk_pct}%"
|
||||
exit 0
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Periodic Gitea Runner health check
|
||||
|
||||
[Timer]
|
||||
OnBootSec={{ gitea_runner_healthcheck_boot_delay }}
|
||||
OnUnitActiveSec={{ gitea_runner_healthcheck_interval }}
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+13
-13
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -42,20 +42,20 @@ All supported OSes are tested in CI via Molecule scenarios on every PR that chan
|
||||
|
||||
## User Documentation
|
||||
|
||||
- [Getting Started](Getting-Started.-) — Installation, quick start, token setup, first run, log viewing
|
||||
- [Getting Started](Getting-Started) — Installation, quick start, token setup, first run, log viewing
|
||||
- [Installation](Installation) — Prerequisites, setup methods, multiple instances, runner registry
|
||||
- [CLI Commands](CLI-Commands.-) — All commands with arguments, options, and examples
|
||||
- [CLI Commands](CLI-Commands) — All commands with arguments, options, and examples
|
||||
- [Troubleshooting](Troubleshooting) — Common issues, diagnostics, and solutions
|
||||
- [FAQ](FAQ) — Frequently asked questions
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
- [Architecture](Architecture) — High-level design, component diagram, data flow, security model, per-runner isolation
|
||||
- [Development Setup](Development-Setup.-) — Environment setup, project structure, dependencies, linting, testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow.-) — PR workflow, branch protection, release pipeline, change classification, badge generation
|
||||
- [Testing Strategy](Testing-Strategy.-) — Unit tests, Molecule scenarios, integration tests, CI distribution
|
||||
- [Decision Log](Decision-Log.-) — Key technical decisions and rationale (ADRs)
|
||||
- [Contributing Guide](Contributing-Guide.-) — Coding standards, PR workflow, commit conventions, Ansible role conventions
|
||||
- [Development Setup](Development-Setup) — Environment setup, project structure, dependencies, linting, testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — PR workflow, branch protection, release pipeline, change classification, badge generation
|
||||
- [Testing Strategy](Testing-Strategy) — Unit tests, Molecule scenarios, integration tests, CI distribution
|
||||
- [Decision Log](Decision-Log) — Key technical decisions and rationale (ADRs)
|
||||
- [Contributing Guide](Contributing-Guide) — Coding standards, PR workflow, commit conventions, Ansible role conventions
|
||||
|
||||
## Quick Links
|
||||
|
||||
|
||||
@@ -299,5 +299,5 @@ All CLI options can be set via environment variables (loaded from `.env` via pyt
|
||||
| `GITEA_RUNNER_USER` | `install`, `update` | Default SSH user |
|
||||
| `GITEA_RUNNER_KEY` | `install`, `update` | Default SSH key path |
|
||||
| `GITEA_RUNNER_LABELS` | `install` | Default runner labels |
|
||||
| `GRM_LANG` | all | UI language: `en`, `bg`, `de`, `ru`, `zh` |
|
||||
| `GRM_LANG` | all | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
|
||||
| `GRM_LOG_LEVEL` | all | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
|
||||
+18
-18
@@ -1,6 +1,6 @@
|
||||
# FAQ
|
||||
|
||||
### How do I obtain the Gitea registration token?
|
||||
## How do I obtain the Gitea registration token?
|
||||
|
||||
There are three levels of registration tokens, depending on which repositories the runner should serve:
|
||||
|
||||
@@ -10,7 +10,7 @@ There are three levels of registration tokens, depending on which repositories t
|
||||
|
||||
Set the token as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
|
||||
|
||||
### What is the CI_GITEA_TOKEN and do I need it?
|
||||
## What is the CI_GITEA_TOKEN and do I need it?
|
||||
|
||||
`CI_GITEA_TOKEN` is a Gitea admin API token used for optional post-install verification. When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational — the integration test passes/fails based on the `.runner` file and systemd service, not the API check.
|
||||
|
||||
@@ -18,7 +18,7 @@ To generate one: Settings → Applications → Generate New Token, with the `adm
|
||||
|
||||
If you skip it, GRM will still verify the runner correctly — it just won't show the extra API confirmation.
|
||||
|
||||
### How do I skip the sudo password prompt for automation?
|
||||
## How do I skip the sudo password prompt for automation?
|
||||
|
||||
Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to the CLI command. This is recommended for CI/CD pipelines.
|
||||
|
||||
@@ -34,7 +34,7 @@ Then use:
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner --no-ask-become-pass
|
||||
```
|
||||
|
||||
### Can I run multiple runners on the same host?
|
||||
## Can I run multiple runners on the same host?
|
||||
|
||||
Yes. Each runner instance is fully isolated with its own system user (`grm-<name>`), rootless Docker daemon, data directory, and systemd user service. Install additional runners with different `--name` values and manage them independently by name.
|
||||
|
||||
@@ -46,7 +46,7 @@ grm list
|
||||
|
||||
Runners on the same host never interfere with each other or with the host's Docker installation.
|
||||
|
||||
### Why does my runner appear offline after installation?
|
||||
## Why does my runner appear offline after installation?
|
||||
|
||||
Check that `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct, verify the runner service is running with `sudo -u grm-<name> systemctl --user status gitea-runner`, and check the logs for registration errors. You can also confirm the runner appears as **Online** in the Gitea UI under **Actions → Runners**.
|
||||
|
||||
@@ -56,15 +56,15 @@ Common causes:
|
||||
- Rootless Docker daemon not running — check `sudo -u grm-<name> systemctl --user status docker`
|
||||
- Lingering not enabled — check `loginctl show-user grm-<name> | grep Linger`
|
||||
|
||||
### What does the "Event loop is closed" warning mean?
|
||||
## What does the "Event loop is closed" warning mean?
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
### Where are runner connection details stored?
|
||||
## Where are runner connection details stored?
|
||||
|
||||
GRM stores each runner's connection details (host, user, SSH key, Gitea URL, labels) in a local JSON registry at `~/.local/share/grm/runners.json`. After installation, lifecycle commands work by runner name only — you can override any stored value by passing the corresponding flag.
|
||||
|
||||
### How do I update the gitea_runner binary?
|
||||
## How do I update the gitea_runner binary?
|
||||
|
||||
Use the `grm update` command:
|
||||
|
||||
@@ -80,7 +80,7 @@ grm update 192.168.1.10 --user ubuntu --version 1.0.8
|
||||
|
||||
The update command downloads the new binary and replaces the existing one at `/usr/local/bin/gitea_runner`. The runner service is restarted automatically.
|
||||
|
||||
### How do I completely remove a runner?
|
||||
## How do I completely remove a runner?
|
||||
|
||||
Use the `grm remove` command:
|
||||
|
||||
@@ -96,18 +96,18 @@ If the remote host is already gone or unreachable, use `--force` to skip remote
|
||||
grm remove prod-runner --force
|
||||
```
|
||||
|
||||
### What is the difference between disable and remove?
|
||||
## What is the difference between disable and remove?
|
||||
|
||||
- **`grm disable <name>`** — Deregisters the runner from Gitea and stops the service, but leaves the user, directories, and service files in place. The runner can be re-enabled later with `grm enable` and re-registered with a new token.
|
||||
- **`grm remove <name>`** — Completely removes the runner: deregisters from Gitea, stops and disables the service, removes the system user, deletes all directories, and removes the local registry entry. This is irreversible.
|
||||
|
||||
### What operating systems are supported?
|
||||
## What operating systems are supported?
|
||||
|
||||
GRM supports Arch Linux (rolling), Ubuntu 22.04/24.04, and Debian 12. All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files.
|
||||
|
||||
### How do I change the UI language?
|
||||
## How do I change the UI language?
|
||||
|
||||
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese).
|
||||
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese), `pl` (Polish).
|
||||
|
||||
```bash
|
||||
GRM_LANG=bg grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
@@ -119,7 +119,7 @@ Or set it in your `.env` file:
|
||||
GRM_LANG=bg
|
||||
```
|
||||
|
||||
### How do I enable debug logging?
|
||||
## How do I enable debug logging?
|
||||
|
||||
Set the `GRM_LOG_LEVEL` environment variable to `DEBUG`:
|
||||
|
||||
@@ -129,7 +129,7 @@ GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
|
||||
The log file at `~/.local/state/grm/logs/grm.log` always captures DEBUG level regardless of this setting. Ansible execution logs are stored in timestamped files at `~/.local/state/grm/logs/ansible-<timestamp>.log`.
|
||||
|
||||
### What runner labels should I use?
|
||||
## What runner labels should I use?
|
||||
|
||||
By default, runners are registered with `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04`. You can override this with `--labels` or the `GITEA_RUNNER_LABELS` environment variable.
|
||||
|
||||
@@ -142,7 +142,7 @@ grm install 192.168.1.10 --user ubuntu --name prod-runner \
|
||||
--labels "docker:docker://gitea/runner-images:ubuntu-latest"
|
||||
```
|
||||
|
||||
### Is GRM secure?
|
||||
## Is GRM secure?
|
||||
|
||||
Yes. GRM is designed with security as a first-class concern:
|
||||
|
||||
@@ -151,7 +151,7 @@ Yes. GRM is designed with security as a first-class concern:
|
||||
- **No shell injection**: The CLI never uses `shell=True` with subprocess.
|
||||
- **Bandit security scan**: The CI pipeline runs Bandit on every PR.
|
||||
|
||||
### Can I install GRM via pip?
|
||||
## Can I install GRM via pip?
|
||||
|
||||
Yes:
|
||||
|
||||
@@ -161,7 +161,7 @@ pip install gitea-runner-manager
|
||||
|
||||
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks and role are bundled with the package. For development or access to Make targets, clone the repository instead.
|
||||
|
||||
### How does GRM handle idempotence?
|
||||
## How does GRM handle idempotence?
|
||||
|
||||
The Ansible role is idempotent — running `grm install` twice produces zero changes on the second run. Each task checks for existing state before making changes. For example:
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ CI_GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
| `GITEA_RUNNER_USER` | No | current login | Default SSH user (overrides `--user`) |
|
||||
| `GITEA_RUNNER_KEY` | No | — | Default SSH key path (overrides `--key`) |
|
||||
| `GITEA_RUNNER_LABELS` | No | — | Default runner labels (overrides `--labels`) |
|
||||
| `GRM_LANG` | No | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh` |
|
||||
| `GRM_LANG` | No | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
|
||||
| `GRM_LOG_LEVEL` | No | `INFO` | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
|
||||
## Step 3: Install Your First Runner
|
||||
@@ -182,7 +182,7 @@ grm disable prod-runner # Disable and deregister the runner
|
||||
grm remove prod-runner # Remove the runner completely
|
||||
```
|
||||
|
||||
See [CLI Commands](CLI-Commands.-) for the full command reference.
|
||||
See [CLI Commands](CLI-Commands) for the full command reference.
|
||||
|
||||
## View Logs
|
||||
|
||||
@@ -232,6 +232,6 @@ Console output is automatically colorised via `click.style`: operation headers i
|
||||
## Next Steps
|
||||
|
||||
- **Install more runners** on the same or different hosts — see [Installation](Installation)
|
||||
- **Learn all CLI commands** — see [CLI Commands](CLI-Commands.-)
|
||||
- **Learn all CLI commands** — see [CLI Commands](CLI-Commands)
|
||||
- **Troubleshoot issues** — see [Troubleshooting](Troubleshooting)
|
||||
- **Understand the architecture** — see [Architecture](Architecture)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installation
|
||||
|
||||
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](Getting-Started.-) for setup instructions. Do not run from `master` — it may contain unreleased changes.
|
||||
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](Getting-Started) for setup instructions. Do not run from `master` — it may contain unreleased changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
- **SSH server** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
|
||||
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
|
||||
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](Getting-Started.-) for detailed instructions on obtaining tokens.
|
||||
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](Getting-Started) for detailed instructions on obtaining tokens.
|
||||
- **systemd** — Required for user services and lingering. All supported OSes ship with systemd.
|
||||
- **Docker** — Installed automatically by the Ansible role (rootless mode). No pre-existing Docker installation is required.
|
||||
|
||||
@@ -96,7 +96,7 @@ Required variables:
|
||||
| `GITEA_URL` | Your Gitea instance URL (e.g., `https://git.example.com`) |
|
||||
| `GITEA_REGISTRATION_TOKEN` | Runner registration token from Gitea (starts with `GR`) |
|
||||
|
||||
See [Getting Started](Getting-Started.-) for detailed token setup instructions.
|
||||
See [Getting Started](Getting-Started) for detailed token setup instructions.
|
||||
|
||||
## Quick Start Install
|
||||
|
||||
|
||||
+17
-17
@@ -14,9 +14,9 @@ classifiers = [
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
]
|
||||
dependencies = [
|
||||
"python-dotenv>=1.2.2",
|
||||
"click>=8.4.2",
|
||||
"ansible>=14.1.0",
|
||||
"python-dotenv==1.2.2",
|
||||
"click==8.4.2",
|
||||
"ansible==14.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -29,32 +29,32 @@ version = {attr = "gitea_runner_manager.__version__"}
|
||||
# Minimal deps for CI scripts that only need click/dotenv
|
||||
# (detect-changes, discover-runners, pr-review, sync-wiki, badges, etc.)
|
||||
ci = [
|
||||
"pytest>=9.1.1",
|
||||
"pytest-cov>=7.1.0",
|
||||
"build>=1.5.0",
|
||||
"twine>=6.2.0",
|
||||
"pytest==9.1.1",
|
||||
"pytest-cov==7.1.0",
|
||||
"build==1.5.0",
|
||||
"twine==6.2.0",
|
||||
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
|
||||
"devx>=0.26.0",
|
||||
"devx==0.27.2",
|
||||
]
|
||||
# Lint and type-checking tools (quality job)
|
||||
lint = [
|
||||
"ruff>=0.15.20",
|
||||
"pyright>=1.1.411",
|
||||
"bandit>=1.9.4",
|
||||
"pip-audit>=2.10.1",
|
||||
"pre-commit>=4.6.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
"ruff==0.15.20",
|
||||
"pyright==1.1.411",
|
||||
"bandit==1.9.4",
|
||||
"pip-audit==2.10.1",
|
||||
"pre-commit==4.6.0",
|
||||
"ansible-lint==26.4.0",
|
||||
]
|
||||
# Molecule testing (molecule-tests job)
|
||||
molecule = [
|
||||
"molecule>=26.4.0",
|
||||
"molecule-docker>=2.1.0",
|
||||
"molecule==26.4.0",
|
||||
"molecule-docker==2.1.0",
|
||||
]
|
||||
# Full dev environment (local development, includes everything)
|
||||
dev = [
|
||||
"gitea-runner-manager[ci,lint,molecule]",
|
||||
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
|
||||
"devx>=0.26.0",
|
||||
"devx==0.27.2",
|
||||
# Non-Python dev dependency: checkmake (Makefile linter)
|
||||
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||
]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
|
||||
|
||||
__version__ = "0.12.0"
|
||||
__version__ = "0.12.5"
|
||||
|
||||
@@ -442,6 +442,44 @@ def _collect_become_pass(ask_become_pass: bool) -> str | None:
|
||||
return sys.stdin.readline().strip() or None
|
||||
|
||||
|
||||
@cli.command(name="health", help=_("Run health check on one or all registered runners."))
|
||||
@click.argument("runner_name", required=False)
|
||||
@_runner_options
|
||||
@_handle_errors("Health check failed: {error}")
|
||||
def health(
|
||||
runner_name: str | None,
|
||||
host: str | None,
|
||||
user: str | None,
|
||||
key: str | None,
|
||||
ask_become_pass: bool,
|
||||
) -> None:
|
||||
"""Check Docker, runner service, and disk health on remote hosts."""
|
||||
become_pass = _collect_become_pass(ask_become_pass)
|
||||
manager = RunnerManager()
|
||||
results = manager.health(
|
||||
name=runner_name,
|
||||
host=host,
|
||||
user=user,
|
||||
key=key,
|
||||
ask_become_pass=ask_become_pass,
|
||||
become_pass=become_pass,
|
||||
become_password_file=_get_become_password_file(),
|
||||
verbose=_get_verbose(),
|
||||
)
|
||||
if not results:
|
||||
click.echo(_("No runners registered. Use 'grm install' to add one."))
|
||||
return
|
||||
click.echo(f"{_('NAME'):<18} {_('HOST'):<16} {_('HEALTHY'):<10} {_('MESSAGE')}")
|
||||
click.echo("-" * 80)
|
||||
all_healthy = True
|
||||
for r in results:
|
||||
if r["healthy"] != "yes":
|
||||
all_healthy = False
|
||||
click.echo(f"{r['name']:<18} {r['host']:<16} {r['healthy']:<10} {r['message']}")
|
||||
if not all_healthy:
|
||||
raise click.ClickException(_("One or more runners are unhealthy"))
|
||||
|
||||
|
||||
@cli.command(name="list", help=_("List all registered runners with live status."))
|
||||
@click.option(
|
||||
"--ask-become-pass/--no-ask-become-pass",
|
||||
|
||||
@@ -487,6 +487,68 @@ class RunnerManager:
|
||||
)
|
||||
return result
|
||||
|
||||
def health(
|
||||
self,
|
||||
name: str | None = None,
|
||||
host: str | None = None,
|
||||
user: str | None = None,
|
||||
key: str | None = None,
|
||||
ask_become_pass: bool = False,
|
||||
become_pass: str | None = None,
|
||||
become_password_file: str | None = None,
|
||||
verbose: bool = False,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Run health check on one or all registered runners.
|
||||
|
||||
When *name* is provided, checks only that runner. Otherwise,
|
||||
checks all registered runners. Returns a list of dicts with
|
||||
``name``, ``host``, ``healthy`` (``"yes"``/``"no"``), and
|
||||
``message`` keys.
|
||||
"""
|
||||
if name:
|
||||
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
|
||||
entries = [(name, actual_host, actual_user, actual_key)]
|
||||
else:
|
||||
entries = [(n, info["host"], info["user"], info.get("key")) for n, info in self._registry.list().items()]
|
||||
|
||||
results: list[dict[str, str]] = []
|
||||
for runner_name, r_host, r_user, r_key in entries:
|
||||
say(_("Checking health of {name} on {host}", name=runner_name, host=r_host))
|
||||
healthy = "no"
|
||||
message = "unknown"
|
||||
try:
|
||||
stdout = self._executor.run_ad_hoc(
|
||||
r_host,
|
||||
r_user,
|
||||
r_key,
|
||||
"shell",
|
||||
f"sudo -u grm-{runner_name} "
|
||||
f"XDG_RUNTIME_DIR=/run/user/$(id -u grm-{runner_name}) "
|
||||
f"systemctl --user start runner-healthcheck.service && "
|
||||
f"journalctl --user -u runner-healthcheck.service --no-pager -n 1",
|
||||
become=True,
|
||||
ask_become_pass=ask_become_pass or become_password_file is not None,
|
||||
check=False,
|
||||
become_pass=become_pass,
|
||||
)
|
||||
if "OK:" in stdout:
|
||||
healthy = "yes"
|
||||
# Extract the OK line
|
||||
for line in stdout.splitlines():
|
||||
if "OK:" in line:
|
||||
message = line.split("OK:", 1)[1].strip()
|
||||
break
|
||||
else:
|
||||
for line in stdout.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and "CHANGED" not in stripped and "WARNING" not in stripped:
|
||||
message = stripped
|
||||
break
|
||||
except AnsibleError as e:
|
||||
message = str(e)
|
||||
results.append({"name": runner_name, "host": r_host, "healthy": healthy, "message": message})
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _parse_status(stdout: str) -> str:
|
||||
ansible_noise = (" | CHANGED | ", " | FAILED | ", " | UNREACHABLE | ", "[WARNING]", "ssh:", ">>")
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
"ru": "Проверить состояние зарегистрированного Gitea Runner.",
|
||||
"zh": "检查已注册的 Gitea Runner 状态。"
|
||||
},
|
||||
"Checking health of {name} on {host}": {
|
||||
"bg": "Проверка на здравословното състояние на {name} на {host}",
|
||||
"de": "Gesundheitsprüfung von {name} auf {host}",
|
||||
"en": "Checking health of {name} on {host}",
|
||||
"pl": "Sprawdzanie zdrowia {name} na {host}",
|
||||
"ru": "Проверка здоровья {name} на {host}",
|
||||
"zh": "正在检查 {host} 上 {name} 的健康状态"
|
||||
},
|
||||
"Checking status of Gitea Runner {name} on {host}": {
|
||||
"bg": "Проверка на състоянието на Gitea Runner {name} на {host}",
|
||||
"de": "Prüfe Status von Gitea Runner {name} auf {host}",
|
||||
@@ -175,6 +183,22 @@
|
||||
"ru": "Токен админ API Gitea для интеграционного теста (env: CI_GITEA_TOKEN)",
|
||||
"zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: CI_GITEA_TOKEN)"
|
||||
},
|
||||
"HEALTHY": {
|
||||
"bg": "ЗДРАВ",
|
||||
"de": "GESUND",
|
||||
"en": "HEALTHY",
|
||||
"pl": "ZDROWY",
|
||||
"ru": "ЗДОРОВ",
|
||||
"zh": "健康"
|
||||
},
|
||||
"Health check failed: {error}": {
|
||||
"bg": "Проверката на здравословното състояние неуспешна: {error}",
|
||||
"de": "Gesundheitsprüfung fehlgeschlagen: {error}",
|
||||
"en": "Health check failed: {error}",
|
||||
"pl": "Sprawdzanie zdrowia nie powiodło się: {error}",
|
||||
"ru": "Проверка здоровья не удалась: {error}",
|
||||
"zh": "健康检查失败: {error}"
|
||||
},
|
||||
"HOST": {
|
||||
"bg": "ХОСТ",
|
||||
"de": "HOST",
|
||||
@@ -239,6 +263,14 @@
|
||||
"ru": "Ошибка списка: {error}",
|
||||
"zh": "列表失败: {error}"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"bg": "СЪОБЩЕНИЕ",
|
||||
"de": "MELDUNG",
|
||||
"en": "MESSAGE",
|
||||
"pl": "WIADOMOŚĆ",
|
||||
"ru": "СООБЩЕНИЕ",
|
||||
"zh": "消息"
|
||||
},
|
||||
"NAME": {
|
||||
"bg": "ИМЕ",
|
||||
"de": "NAME",
|
||||
@@ -263,6 +295,14 @@
|
||||
"ru": "Нет зарегистрированных runners. Используйте 'grm install' чтобы добавить.",
|
||||
"zh": "没有已注册的 runners。使用 'grm install' 添加一个。"
|
||||
},
|
||||
"One or more runners are unhealthy": {
|
||||
"bg": "Един или повече runners са нездравословни",
|
||||
"de": "Ein oder mehrere Runner sind fehlerhaft",
|
||||
"en": "One or more runners are unhealthy",
|
||||
"pl": "Jeden lub więcej runnerów jest w złym stanie",
|
||||
"ru": "Один или несколько runners нездоровы",
|
||||
"zh": "一个或多个 runners 不健康"
|
||||
},
|
||||
"Override SSH key from registry": {
|
||||
"bg": "Замяна на SSH ключа от регистъра",
|
||||
"de": "SSH-Schlüssel aus Registrierung überschreiben",
|
||||
@@ -423,6 +463,14 @@
|
||||
"ru": "Выполнение Ansible playbook",
|
||||
"zh": "正在运行 Ansible playbook"
|
||||
},
|
||||
"Run health check on one or all registered runners.": {
|
||||
"bg": "Проверка на здравословното състояние на един или всички регистрирани runners.",
|
||||
"de": "Gesundheitsprüfung für einen oder alle registrierten Runner ausführen.",
|
||||
"en": "Run health check on one or all registered runners.",
|
||||
"pl": "Uruchom sprawdzanie zdrowia jednego lub wszystkich zarejestrowanych runnerów.",
|
||||
"ru": "Проверить здоровье одного или всех зарегистрированных runners.",
|
||||
"zh": "对一个或所有已注册 runners 运行健康检查。"
|
||||
},
|
||||
"SSH user (env: GITEA_RUNNER_USER)": {
|
||||
"bg": "SSH потребител (env: GITEA_RUNNER_USER)",
|
||||
"de": "SSH-Benutzer (env: GITEA_RUNNER_USER)",
|
||||
|
||||
@@ -852,6 +852,75 @@ class TestCLI:
|
||||
assert result.exit_code != 0
|
||||
assert "fail" in result.output
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_health_all_healthy(self, mock_manager_class: MagicMock) -> None:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.health.return_value = [
|
||||
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
|
||||
{"name": "r2", "host": "10.0.0.2", "healthy": "yes", "message": "runner healthy, disk at 50%"},
|
||||
]
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["health"], input="secret\n")
|
||||
assert result.exit_code == 0
|
||||
assert "r1" in result.output
|
||||
assert "r2" in result.output
|
||||
assert "yes" in result.output
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_health_with_unhealthy(self, mock_manager_class: MagicMock) -> None:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.health.return_value = [
|
||||
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
|
||||
{"name": "r2", "host": "10.0.0.2", "healthy": "no", "message": "Docker daemon down"},
|
||||
]
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["health"], input="secret\n")
|
||||
assert result.exit_code != 0
|
||||
assert "unhealthy" in result.output.lower()
|
||||
assert "r2" in result.output
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_health_single_runner(self, mock_manager_class: MagicMock) -> None:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.health.return_value = [
|
||||
{"name": "r1", "host": "10.0.0.1", "healthy": "yes", "message": "runner healthy, disk at 42%"},
|
||||
]
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["health", "r1"], input="secret\n")
|
||||
assert result.exit_code == 0
|
||||
assert "r1" in result.output
|
||||
mock_manager.health.assert_called_once()
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_health_empty(self, mock_manager_class: MagicMock) -> None:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.health.return_value = []
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["health"], input="secret\n")
|
||||
assert result.exit_code == 0
|
||||
assert "No runners registered" in result.output
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_health_error(self, mock_manager_class: MagicMock) -> None:
|
||||
mock_manager = MagicMock()
|
||||
from gitea_runner_manager.exceptions import AnsibleError
|
||||
|
||||
mock_manager.health.side_effect = AnsibleError("fail")
|
||||
mock_manager_class.return_value = mock_manager
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["health"], input="secret\n")
|
||||
assert result.exit_code != 0
|
||||
assert "fail" in result.output
|
||||
|
||||
@patch("gitea_runner_manager.cli.os.getlogin", side_effect=OSError("no tty"))
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_default_user_fallback_on_getlogin_error(
|
||||
|
||||
@@ -625,6 +625,112 @@ class TestRunnerManager:
|
||||
assert manager.list_runners(no_status=True) == []
|
||||
|
||||
|
||||
class TestHealth:
|
||||
"""Tests for the ``health`` method."""
|
||||
|
||||
def test_health_single_runner_healthy(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": "/key"}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 42%"
|
||||
manager._executor = mock_executor
|
||||
|
||||
results = manager.health(name="r1")
|
||||
assert len(results) == 1
|
||||
assert results[0]["name"] == "r1"
|
||||
assert results[0]["healthy"] == "yes"
|
||||
assert "runner healthy" in results[0]["message"]
|
||||
mock_executor.run_ad_hoc.assert_called_once()
|
||||
|
||||
def test_health_single_runner_unhealthy(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
mock_executor.run_ad_hoc.return_value = "CRITICAL: Docker daemon still down after restart"
|
||||
manager._executor = mock_executor
|
||||
|
||||
results = manager.health(name="r1")
|
||||
assert len(results) == 1
|
||||
assert results[0]["healthy"] == "no"
|
||||
assert "Docker daemon still down" in results[0]["message"]
|
||||
|
||||
def test_health_all_runners(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.list.return_value = {
|
||||
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
|
||||
"r2": {"host": "10.0.0.2", "user": "ubuntu", "key": None},
|
||||
}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
mock_executor.run_ad_hoc.side_effect = [
|
||||
"OK: runner healthy, disk at 42%",
|
||||
"ERROR: gitea-runner service is inactive, restarting",
|
||||
]
|
||||
manager._executor = mock_executor
|
||||
|
||||
results = manager.health()
|
||||
assert len(results) == 2
|
||||
assert results[0]["healthy"] == "yes"
|
||||
assert results[1]["healthy"] == "no"
|
||||
|
||||
def test_health_empty_registry(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.list.return_value = {}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
results = manager.health()
|
||||
assert results == []
|
||||
|
||||
def test_health_ansible_error(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
mock_executor.run_ad_hoc.side_effect = AnsibleError("ssh unreachable")
|
||||
manager._executor = mock_executor
|
||||
|
||||
results = manager.health(name="r1")
|
||||
assert len(results) == 1
|
||||
assert results[0]["healthy"] == "no"
|
||||
assert "ssh unreachable" in results[0]["message"]
|
||||
|
||||
def test_health_with_host_override(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 50%"
|
||||
manager._executor = mock_executor
|
||||
|
||||
results = manager.health(name="r1", host="10.0.0.99", user="root")
|
||||
assert len(results) == 1
|
||||
assert results[0]["host"] == "10.0.0.99"
|
||||
call_args = mock_executor.run_ad_hoc.call_args.args
|
||||
assert call_args[0] == "10.0.0.99"
|
||||
assert call_args[1] == "root"
|
||||
|
||||
def test_health_runner_not_found(self) -> None:
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get.return_value = None
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
with pytest.raises(AnsibleError, match="not found in registry"):
|
||||
manager.health(name="nonexistent")
|
||||
|
||||
def test_health_passes_become_pass(self) -> None:
|
||||
"""become_pass is forwarded to run_ad_hoc for sudo authentication."""
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get.return_value = {"host": "10.0.0.1", "user": "ubuntu", "key": None}
|
||||
manager = RunnerManager(registry=mock_registry)
|
||||
mock_executor = MagicMock()
|
||||
mock_executor.run_ad_hoc.return_value = "OK: runner healthy, disk at 42%"
|
||||
manager._executor = mock_executor
|
||||
|
||||
manager.health(name="r1", become_pass="s3cr3t")
|
||||
call_kwargs = mock_executor.run_ad_hoc.call_args.kwargs
|
||||
assert call_kwargs["become_pass"] == "s3cr3t"
|
||||
|
||||
|
||||
class TestExtraVarsFile:
|
||||
"""Tests for the ``_extra_vars_file`` context manager."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user