GRM-148: chore: bump devx to v0.41.1, update deps and runner version
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run

This commit was merged in pull request #218.
This commit is contained in:
2026-07-13 03:24:35 +00:00
parent 9ea7ae656b
commit f6a4f1fe43
81 changed files with 438 additions and 437 deletions
+2 -2
View File
@@ -178,13 +178,13 @@ jobs:
- name: Discover assigned test pairs
env:
RUNNER_INDEX: ${{ matrix.runner-index }}
MAX_RUNNERS: ${{ needs.validate.outputs.runner-count }}
MAX_RUNNERS: 6
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.distribute_molecule \
--runner-index "$RUNNER_INDEX" \
--max-runners "$MAX_RUNNERS" \
--github-env --skip-if-excess
--github-env
- name: Run molecule tests
if: env.SKIP != 'true'
env:
+1 -1
View File
@@ -57,7 +57,7 @@ CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is
## Architecture
- **Python CLI** (`src/grm/`) — Click-based CLI that delegates to Ansible
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup with pasta networking (IPv6 support)
- **Ansible Role** (`ansible/roles/gitea_runner/`) — Idempotent role for rootless Docker runner setup with pasta networking (IPv6 support)
- **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
+1 -1
View File
@@ -184,7 +184,7 @@ test-integration:
$(BIN)/pytest tests/integration/ -v --no-cov
MOLECULE := $(realpath $(BIN))/molecule
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea_runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
# Quick local test: Ubuntu 22.04 only, all scenarios
molecule:
+4 -4
View File
@@ -266,8 +266,8 @@ One of GRM's core features is the ability to run multiple isolated runners on th
- **Dedicated system user**: `grm-<name>` with its own home directory at `/home/grm-<name>/`
- **Rootless Docker daemon**: Isolated Docker socket at `/run/user/<UID>/docker.sock`
- **Data directory**: `/var/lib/gitea-runner/<name>/`
- **Config directory**: `/etc/gitea-runner/<name>/`
- **Data directory**: `/var/lib/gitea_runner/<name>/`
- **Config directory**: `/etc/gitea_runner/<name>/`
- **Systemd user service**: `gitea-runner.service` (independent start/stop/enable)
- **Docker prune timer**: Per-instance daily cleanup
@@ -341,13 +341,13 @@ GRM consists of two layers:
1. **Python CLI** (`src/grm/`) — Built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess. Secrets are passed via temporary JSON files to avoid exposure in the process list.
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer.
2. **Ansible Role** (`ansible/roles/gitea_runner/`) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer.
```text
grm install <host>
└── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner
└── role: gitea_runner
├── user_setup.yml (create per-runner system user + lingering)
├── rootless_docker.yml (rootless Docker setup under runner user)
├── install_runner.yml (download binary, config, register, service)
+6 -6
View File
@@ -6,13 +6,13 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
@@ -21,7 +21,7 @@
- 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) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
@@ -30,14 +30,14 @@
- name: Include deregistration
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
when: not gitea_runner_skip_registration | default(false)
- name: Disable gitea-runner user service
ansible.builtin.command: systemctl --user disable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
+3 -3
View File
@@ -6,13 +6,13 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Enable gitea-runner user service
ansible.builtin.command: systemctl --user enable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
@@ -21,7 +21,7 @@
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
+1 -1
View File
@@ -3,4 +3,4 @@
hosts: all
become: true
roles:
- role: gitea-runner
- role: gitea_runner
+34 -34
View File
@@ -6,11 +6,11 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Get runner user UID
ansible.builtin.command: id -u "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
ansible.builtin.command: id -u "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
register: runner_uid_result
changed_when: false
failed_when: false
@@ -23,20 +23,20 @@
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
when: gitea_runner_systemd_available.stat.exists
changed_when: true
failed_when: false
- name: Disable gitea-runner user service
ansible.builtin.command: systemctl --user disable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
when: gitea_runner_systemd_available.stat.exists
changed_when: true
failed_when: false
@@ -47,7 +47,7 @@
args:
executable: /bin/bash
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
@@ -57,7 +57,7 @@
- name: Prune all Docker images, volumes, and build cache (rootless)
ansible.builtin.command: docker system prune -af --volumes
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
@@ -67,7 +67,7 @@
- name: Stop rootless Docker daemon
ansible.builtin.command: systemctl --user stop docker
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
changed_when: true
@@ -75,120 +75,120 @@
- name: Include deregistration
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
when: not gitea_runner_skip_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) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
when: gitea_runner_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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/docker-prune.service"
state: absent
failed_when: false
- name: Remove docker-prune user timer file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.timer"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/docker-prune.timer"
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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_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"
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ gitea_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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/gitea-runner.service"
state: absent
when: remove_systemd_template | default(true)
- name: Kill remaining processes of runner user
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
failed_when: false
changed_when: true
- name: Wait for processes to terminate
ansible.builtin.command: "pkill -u {{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
ansible.builtin.command: "pkill -u {{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
failed_when: false
changed_when: false
- name: Disable lingering for runner user
ansible.builtin.command: loginctl disable-linger "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
ansible.builtin.command: loginctl disable-linger "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
failed_when: false
changed_when: true
- name: Remove runner user and home directory
ansible.builtin.user:
name: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
name: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
state: absent
remove: true
when: remove_runner_user | default(true)
when: gitea_runner_remove_user | default(true)
failed_when: false
- name: Remove Docker data root when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.local/share/docker"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.local/share/docker"
state: absent
when: not (remove_runner_user | default(true))
when: not (gitea_runner_remove_user | default(true))
failed_when: false
- name: Remove act cache when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.cache/act"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.cache/act"
state: absent
when: not (remove_runner_user | default(true))
when: not (gitea_runner_remove_user | default(true))
failed_when: false
- name: Remove systemd user config dir when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user"
state: absent
when: not (remove_runner_user | default(true))
when: not (gitea_runner_remove_user | default(true))
failed_when: false
- name: Remove subuid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subuid
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:"
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}:"
state: absent
failed_when: false
- name: Remove subgid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subgid
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:"
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}:"
state: absent
failed_when: false
- name: Remove runner data directory
ansible.builtin.file:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}"
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ gitea_runner_name) }}"
state: absent
- name: Remove runner config directory
ansible.builtin.file:
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}"
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ gitea_runner_name) }}"
state: absent
+1 -1
View File
@@ -2,6 +2,6 @@ collections:
- name: community.general
version: "==13.1.0"
- name: ansible.posix
version: "==2.2.0"
version: "==2.2.1"
- name: community.docker
version: "==5.2.1"
+2 -2
View File
@@ -7,12 +7,12 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "deregister-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "lifecycle-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "remove-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "template-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "update-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,58 +0,0 @@
---
- name: Check if runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_content
when: runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
runner_reg: >
{{ (runner_file_content.content | b64decode | from_json)
if (runner_file_content is defined and runner_file_content.content is defined)
else {} }}
when: runner_file_stat.stat.exists | default(false) | bool
- name: Deregister runner from Gitea via API
ansible.builtin.command: >
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
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
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"
state: absent
when: runner_file_stat.stat.exists | default(false) | bool
@@ -1,102 +0,0 @@
---
- name: Check runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_content
when: runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
runner_reg: >
{{ (runner_file_content.content | b64decode | from_json)
if (runner_file_content is defined and runner_file_content.content is defined)
else {} }}
when: runner_file_stat.stat.exists | default(false) | bool
- name: Verify runner user service 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 }}"
register: service_check
changed_when: false
when:
- systemd_available.stat.exists
- docker_rootless_setup
- name: Validate runner installation
ansible.builtin.fail:
msg: >
Runner '{{ runner_name }}' is not properly installed:
{% if not (runner_file_stat.stat.exists | default(false)) %}
- Registration file (.runner) is missing. Registration may have failed.
{% endif %}
{% if docker_rootless_setup and not (service_check.stdout | default('') | trim) == 'active' %}
- Systemd user service is not active.
{% endif %}
when: >
not (runner_file_stat.stat.exists | default(false))
or (docker_rootless_setup and not (service_check.stdout | default('') | trim) == 'active')
- name: Report runner status
ansible.builtin.debug:
msg: >
Runner '{{ runner_name }}' is installed and running.
Registered: {{ runner_file_stat.stat.exists | default(false) }}
{% if runner_reg.id is defined %}Runner ID: {{ runner_reg.id }}{% endif %}
{% if runner_reg.uuid is defined %}UUID: {{ runner_reg.uuid }}{% endif %}
{% if runner_reg.address is defined %}Gitea: {{ runner_reg.address }}{% endif %}
Service: {{ service_check.stdout | default('unknown') | trim }}
- name: Optional Gitea API verification
when:
- gitea_url is defined
- gitea_admin_token is defined
- gitea_admin_token | length > 0
block:
- name: Check admin runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/admin/runners"
headers:
Authorization: "token {{ gitea_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: admin_api_response
ignore_errors: true
- name: Check repo runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/repos/{{ gitea_runner_test_repo | default('oblachno-oss/grm') }}/actions/runners"
headers:
Authorization: "token {{ gitea_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: repo_api_response
ignore_errors: true
- name: Report API status (informational only)
ansible.builtin.debug:
msg: >
API checks (informational only — not used for pass/fail):
Admin API: {{ admin_api_response.status | default('no response') }}.
Repo API: {{ repo_api_response.status | default('no response') }}.
{% if admin_api_response.json.runners | default([]) | selectattr('name', 'equalto', runner_name) | list | length > 0 %}
Runner found in admin API.
{% endif %}
{% if repo_api_response.json.runners | default([]) | selectattr('name', 'equalto', runner_name) | list | length > 0 %}
Runner found in repo API.
{% endif %}
rescue:
- name: API check failed
ansible.builtin.debug:
msg: "API verification skipped due to connection or permission error."
@@ -1,19 +1,19 @@
---
gitea_runner_version: "1.0.8"
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04"
skip_runner_registration: false
gitea_runner_version: "2.0.1"
gitea_runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04"
gitea_runner_skip_registration: false
# Per-runner user (rootless isolation)
gitea_runner_user_prefix: "grm-"
gitea_runner_base_home: "/home"
gitea_runner_service_user: "{{ gitea_runner_user_prefix }}{{ runner_name }}"
gitea_runner_service_user: "{{ gitea_runner_user_prefix }}{{ gitea_runner_name }}"
gitea_runner_home: "{{ gitea_runner_base_home }}/{{ gitea_runner_service_user }}"
# Base paths (instance-scoped via runner_name)
# Base paths (instance-scoped via gitea_runner_name)
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner"
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ gitea_runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ gitea_runner_name }}"
gitea_runner_binary_path: "/usr/local/bin/gitea_runner"
# Prune configuration
@@ -33,11 +33,11 @@ gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck
# 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: ""
gitea_runner_admin_token: ""
# Removal defaults
remove_systemd_template: true
remove_runner_user: true
gitea_runner_remove_systemd_template: true
gitea_runner_remove_user: true
# Runner configuration
gitea_runner_log_level: "info"
@@ -45,18 +45,18 @@ gitea_runner_container_label: "gitea-runner=true"
gitea_runner_file: ".runner"
# Docker installation (for rootless dependencies)
docker_gpg_key_path: "/etc/apt/keyrings/docker.gpg"
docker_apt_arch: "{{ 'amd64' if ansible_facts['architecture'] == 'x86_64' else ansible_facts['architecture'] }}"
docker_apt_source_line: >-
deb [arch={{ docker_apt_arch }} signed-by={{ docker_gpg_key_path }}]
gitea_runner_docker_gpg_key_path: "/etc/apt/keyrings/docker.gpg"
gitea_runner_docker_apt_arch: "{{ 'amd64' if ansible_facts['architecture'] == 'x86_64' else ansible_facts['architecture'] }}"
gitea_runner_docker_apt_source_line: >-
deb [arch={{ gitea_runner_docker_apt_arch }} signed-by={{ gitea_runner_docker_gpg_key_path }}]
https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}
{{ ansible_facts['distribution_release'] }} stable
# Set to false in CI/molecule to skip rootless daemon startup (needs kernel userns)
docker_rootless_setup: true
gitea_runner_docker_rootless_setup: true
# Rootless Docker network driver: "pasta" (IPv6 support) or "slirp4netns" (IPv4 only)
# pasta has proper outgoing IPv6 support; slirp4netns does not (known limitation).
docker_rootless_net_driver: "pasta"
gitea_runner_docker_rootless_net_driver: "pasta"
# IPv6 subnet for rootless Docker containers (ULA range, not routable on internet)
docker_ipv6_cidr: "fd00:dead:beef::/48"
gitea_runner_docker_ipv6_cidr: "fd00:dead:beef::/48"
@@ -9,4 +9,4 @@
when:
- ansible_facts is defined
- ansible_facts['service_mgr'] | default('') == 'systemd'
- docker_rootless_setup
- gitea_runner_docker_rootless_setup
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "molecule-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "molecule-test-runner"
gitea_runner_name: "molecule-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "deregister-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
gitea_runner_name: "deregister-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -13,7 +13,7 @@
ansible.builtin.copy:
dest: "{{ gitea_runner_data_dir }}/.runner"
content: |
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ runner_name }}", "address": "http://localhost:3000"}
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ gitea_runner_name }}", "address": "http://localhost:3000"}
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
@@ -22,12 +22,12 @@
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
gitea_runner_name: "deregister-test-runner"
registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000"
skip_runner_registration: false
gitea_runner_skip_registration: false
tasks:
- name: Include deregistration tasks
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: deregister.yml
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
gitea_runner_name: "deregister-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -12,12 +12,12 @@
- name: Check registration file was removed
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
register: gitea_runner_file_stat
- name: Assert registration file no longer exists
ansible.builtin.assert:
that:
- not runner_file_stat.stat.exists
- not gitea_runner_file_stat.stat.exists
fail_msg: "Registration file (.runner) was not removed by deregistration"
- name: Check systemd user service still exists
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "lifecycle-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
gitea_runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -23,7 +23,7 @@
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
gitea_runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
gitea_runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -5,11 +5,11 @@
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-a"
skip_runner_registration: true
docker_rootless_setup: false
gitea_runner_name: "molecule-runner-a"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea-runner
- role: gitea_runner
- name: Converge second runner instance
hosts: all
@@ -17,8 +17,8 @@
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-b"
skip_runner_registration: true
docker_rootless_setup: false
gitea_runner_name: "molecule-runner-b"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea-runner
- role: gitea_runner
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "remove-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -2,7 +2,7 @@
- name: Remove runner via remove-runner playbook
ansible.builtin.import_playbook: "../../../../remove-runner.yml"
vars:
runner_name: "remove-test-runner"
gitea_runner_name: "remove-test-runner"
registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000"
skip_runner_registration: true
gitea_runner_skip_registration: true
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "remove-test-runner"
gitea_runner_name: "remove-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "template-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "template-test-runner"
gitea_runner_name: "template-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "update-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,9 +3,9 @@
hosts: all
become: true
vars:
runner_name: "update-test-runner"
gitea_runner_name: "update-test-runner"
tasks:
- name: Include update tasks
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: update_runner.yml
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "update-test-runner"
gitea_runner_name: "update-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -0,0 +1,58 @@
---
- name: Check if runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: gitea_runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: gitea_runner_file_content
when: gitea_runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
gitea_runner_reg: >
{{ (gitea_runner_file_content.content | b64decode | from_json)
if (gitea_runner_file_content is defined and gitea_runner_file_content.content is defined)
else {} }}
when: gitea_runner_file_stat.stat.exists | default(false) | bool
- name: Deregister runner from Gitea via API
ansible.builtin.command: >
curl -sf --connect-timeout 5 --max-time 10 -X DELETE
-H "Authorization: token {{ gitea_runner_admin_token | default(registration_token) }}"
"{{ gitea_url }}/api/v1/admin/actions/runners/{{ gitea_runner_reg.id }}"
args:
chdir: "{{ gitea_runner_data_dir }}"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
when:
- gitea_runner_file_stat.stat.exists | default(false) | bool
- not gitea_runner_skip_registration
- gitea_runner_reg.id is defined
register: gitea_runner_deregister_output
changed_when: gitea_runner_deregister_output.rc == 0
failed_when: false
- name: Warn if deregistration failed
ansible.builtin.debug:
msg: >-
WARNING: Runner deregistration from Gitea failed (rc={{ gitea_runner_deregister_output.rc | default('N/A') }}).
The runner entry may remain in Gitea's admin UI as offline.
Use an admin token (gitea_runner_admin_token var) to enable automatic cleanup,
or remove it manually from {{ gitea_url }}/-/admin/actions/runners
when:
- gitea_runner_file_stat.stat.exists | default(false) | bool
- not gitea_runner_skip_registration
- gitea_runner_deregister_output is defined
- gitea_runner_deregister_output.rc | default(1) != 0
- name: Remove runner registration file
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}/.runner"
state: absent
when: gitea_runner_file_stat.stat.exists | default(false) | bool
@@ -31,8 +31,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Enable and start healthcheck user timer
ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer
@@ -42,5 +42,5 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
@@ -15,7 +15,7 @@
- name: Include registration
ansible.builtin.include_tasks: register.yml
when: not skip_runner_registration
when: not gitea_runner_skip_registration
- name: Include service setup
ansible.builtin.include_tasks: service.yml
@@ -0,0 +1,102 @@
---
- name: Check runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: gitea_runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: gitea_runner_file_content
when: gitea_runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
gitea_runner_reg: >
{{ (gitea_runner_file_content.content | b64decode | from_json)
if (gitea_runner_file_content is defined and gitea_runner_file_content.content is defined)
else {} }}
when: gitea_runner_file_stat.stat.exists | default(false) | bool
- name: Verify runner user service 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 }}"
register: gitea_runner_service_check
changed_when: false
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Validate runner installation
ansible.builtin.fail:
msg: >
Runner '{{ gitea_runner_name }}' is not properly installed:
{% if not (gitea_runner_file_stat.stat.exists | default(false)) %}
- Registration file (.runner) is missing. Registration may have failed.
{% endif %}
{% if gitea_runner_docker_rootless_setup and not (gitea_runner_service_check.stdout | default('') | trim) == 'active' %}
- Systemd user service is not active.
{% endif %}
when: >
not (gitea_runner_file_stat.stat.exists | default(false))
or (gitea_runner_docker_rootless_setup and not (gitea_runner_service_check.stdout | default('') | trim) == 'active')
- name: Report runner status
ansible.builtin.debug:
msg: >
Runner '{{ gitea_runner_name }}' is installed and running.
Registered: {{ gitea_runner_file_stat.stat.exists | default(false) }}
{% if gitea_runner_reg.id is defined %}Runner ID: {{ gitea_runner_reg.id }}{% endif %}
{% if gitea_runner_reg.uuid is defined %}UUID: {{ gitea_runner_reg.uuid }}{% endif %}
{% if gitea_runner_reg.address is defined %}Gitea: {{ gitea_runner_reg.address }}{% endif %}
Service: {{ gitea_runner_service_check.stdout | default('unknown') | trim }}
- name: Optional Gitea API verification
when:
- gitea_url is defined
- gitea_runner_admin_token is defined
- gitea_runner_admin_token | length > 0
block:
- name: Check admin runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/admin/runners"
headers:
Authorization: "token {{ gitea_runner_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: gitea_runner_admin_api_response
ignore_errors: true
- name: Check repo runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/repos/{{ gitea_runner_test_repo | default('oblachno-oss/grm') }}/actions/runners"
headers:
Authorization: "token {{ gitea_runner_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: gitea_runner_repo_api_response
ignore_errors: true
- name: Report API status (informational only)
ansible.builtin.debug:
msg: >
API checks (informational only — not used for pass/fail):
Admin API: {{ gitea_runner_admin_api_response.status | default('no response') }}.
Repo API: {{ gitea_runner_repo_api_response.status | default('no response') }}.
{% if gitea_runner_admin_api_response.json.runners | default([]) | selectattr('name', 'equalto', gitea_runner_name) | list | length > 0 %}
Runner found in admin API.
{% endif %}
{% if gitea_runner_repo_api_response.json.runners | default([]) | selectattr('name', 'equalto', gitea_runner_name) | list | length > 0 %}
Runner found in repo API.
{% endif %}
rescue:
- name: API check failed
ansible.builtin.debug:
msg: "API verification skipped due to connection or permission error."
@@ -19,4 +19,4 @@
- name: Include integration test
ansible.builtin.include_tasks: integration_test.yml
when: not skip_runner_registration
when: not gitea_runner_skip_registration
@@ -23,8 +23,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Enable and start docker-prune user timer
ansible.builtin.command: systemctl --user enable --now docker-prune.timer
@@ -34,5 +34,5 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
@@ -10,15 +10,15 @@
- name: Check if runner is already registered
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_registered
register: gitea_runner_registered
- name: Register runner with Gitea
ansible.builtin.command: >
{{ gitea_runner_binary_path }} register
--token {{ registration_token }}
--name {{ runner_name }}
--name {{ gitea_runner_name }}
--instance {{ gitea_url }}
--labels {{ runner_labels }}
--labels {{ gitea_runner_labels }}
--no-interactive
args:
chdir: "{{ gitea_runner_data_dir }}"
@@ -27,7 +27,7 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
when: not runner_registered.stat.exists
register: register_output
changed_when: "'already exists' not in register_output.stdout | default('')"
when: not gitea_runner_registered.stat.exists
register: gitea_runner_register_output
changed_when: "'already exists' not in gitea_runner_register_output.stdout | default('')"
timeout: 60
@@ -6,14 +6,14 @@
- name: Resolve runner service user
ansible.builtin.set_fact:
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ runner_name }}"
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ gitea_runner_name }}"
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner"
- name: Resolve runner data and config dirs
ansible.builtin.set_fact:
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ gitea_runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ gitea_runner_name }}"
- name: Resolve runner service user UID
ansible.builtin.getent:
@@ -9,18 +9,19 @@
- name: Download and dearmor Docker GPG key (Debian/Ubuntu)
ansible.builtin.shell: |
set -o pipefail
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" | gpg --dearmor --yes -o {{ docker_gpg_key_path }}
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" \
| gpg --dearmor --yes -o {{ gitea_runner_docker_gpg_key_path }}
args:
creates: "{{ docker_gpg_key_path }}"
creates: "{{ gitea_runner_docker_gpg_key_path }}"
executable: /bin/bash
when: ansible_facts['os_family'] == 'Debian'
- name: Add Docker APT repository (Debian/Ubuntu)
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/docker.list
content: "{{ docker_apt_source_line }}\n"
content: "{{ gitea_runner_docker_apt_source_line }}\n"
mode: "0644"
register: docker_apt_repo
register: gitea_runner_docker_apt_repo
when: ansible_facts['os_family'] == 'Debian'
- name: Update apt cache after adding Docker repo (Debian/Ubuntu)
@@ -28,7 +29,7 @@
update_cache: true
when:
- ansible_facts['os_family'] == 'Debian'
- docker_apt_repo is changed
- gitea_runner_docker_apt_repo is changed
- name: Install rootless Docker dependencies (Debian/Ubuntu)
ansible.builtin.apt:
@@ -67,7 +68,7 @@
- name: Check if rootless Docker is already set up
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
register: rootless_docker_check
register: gitea_runner_rootless_docker_check
- name: Set up rootless Docker for runner user
ansible.builtin.command: dockerd-rootless-setuptool.sh install
@@ -78,8 +79,8 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when:
- docker_rootless_setup
- not rootless_docker_check.stat.exists
- gitea_runner_docker_rootless_setup
- not gitea_runner_rootless_docker_check.stat.exists
- name: Start rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user start docker
@@ -88,7 +89,7 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
- name: Enable rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user enable docker
@@ -97,7 +98,7 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
- name: Ensure Docker config directory exists
ansible.builtin.file:
@@ -106,7 +107,7 @@
mode: "0755"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
- name: Ensure systemd user override directory exists
ansible.builtin.file:
@@ -115,21 +116,21 @@
mode: "0755"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
- name: Configure rootless Docker to use pasta with IPv6
ansible.builtin.copy:
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker.service.d/override.conf"
content: |
[Service]
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET={{ docker_rootless_net_driver }}"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET={{ gitea_runner_docker_rootless_net_driver }}"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
register: docker_network_override
when: docker_rootless_setup
register: gitea_runner_docker_network_override
when: gitea_runner_docker_rootless_setup
- name: Reload systemd user daemon if network config changed
ansible.builtin.command: systemctl --user daemon-reload
@@ -139,8 +140,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- docker_rootless_setup
- docker_network_override is changed
- gitea_runner_docker_rootless_setup
- gitea_runner_docker_network_override is changed
- name: Configure rootless Docker daemon with IPv6 enabled
ansible.builtin.copy:
@@ -149,13 +150,13 @@
{
"ipv6": true,
"ip6tables": true,
"fixed-cidr-v6": "{{ docker_ipv6_cidr }}"
"fixed-cidr-v6": "{{ gitea_runner_docker_ipv6_cidr }}"
}
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
register: docker_ipv6_config
when: docker_rootless_setup
register: gitea_runner_docker_ipv6_config
when: gitea_runner_docker_rootless_setup
- name: Restart rootless Docker if config changed
ansible.builtin.command: systemctl --user restart docker
@@ -165,8 +166,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- docker_rootless_setup
- docker_ipv6_config is changed or docker_network_override is changed
- gitea_runner_docker_rootless_setup
- gitea_runner_docker_ipv6_config is changed or gitea_runner_docker_network_override is changed
- name: Wait for rootless Docker daemon to be ready
ansible.builtin.command: docker version
@@ -175,9 +176,9 @@
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_ready
until: docker_ready.rc == 0
register: gitea_runner_docker_ready
until: gitea_runner_docker_ready.rc == 0
retries: 10
delay: 2
changed_when: false
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
@@ -15,8 +15,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Enable and start gitea-runner user service
ansible.builtin.command: systemctl --user enable --now gitea-runner
@@ -26,5 +26,5 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
@@ -2,4 +2,4 @@
- name: Check if systemd is available
ansible.builtin.stat:
path: /run/systemd/system
register: systemd_available
register: gitea_runner_systemd_available
@@ -9,6 +9,6 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when:
- systemd_available.stat.exists | default(false) | bool
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists | default(false) | bool
- gitea_runner_docker_rootless_setup
changed_when: true
@@ -6,21 +6,21 @@
shell: /bin/bash
system: true
create_home: true
register: runner_user
register: gitea_runner_user
- name: Set runner UID fact
ansible.builtin.set_fact:
gitea_runner_uid: "{{ runner_user.uid }}"
gitea_runner_uid: "{{ gitea_runner_user.uid }}"
- name: Check if lingering is already enabled
ansible.builtin.stat:
path: "/var/lib/systemd/linger/{{ gitea_runner_service_user }}"
register: linger_stat
register: gitea_runner_linger_stat
- name: Enable lingering for runner user
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
changed_when: not linger_stat.stat.exists
when: systemd_available.stat.exists
changed_when: not gitea_runner_linger_stat.stat.exists
when: gitea_runner_systemd_available.stat.exists
- name: Ensure subuid entry for runner user
ansible.builtin.lineinfile:
@@ -21,6 +21,6 @@
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_version_output
register: gitea_runner_docker_version_output
changed_when: false
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
+4 -4
View File
@@ -6,12 +6,12 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: resolve_uid.yml
- name: Check if runner is already registered
@@ -21,11 +21,11 @@
- name: Include registration if not registered
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: register.yml
when:
- not runner_registered.stat.exists
- not skip_runner_registration | default(false)
- not gitea_runner_skip_registration | default(false)
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
+2 -2
View File
@@ -6,12 +6,12 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: resolve_uid.yml
- name: Check systemd user service status
+2 -2
View File
@@ -6,12 +6,12 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
+1 -1
View File
@@ -6,5 +6,5 @@
tasks:
- name: Update runner
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: update_runner.yml
+5 -5
View File
@@ -3,7 +3,7 @@
GRM consists of two layers:
1. **Python CLI** (`src/grm/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
2. **Ansible Role** (`ansible/roles/gitea_runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
## High-Level Design
@@ -26,7 +26,7 @@ The Ansible role handles all remote state: user creation, package installation,
grm install <host>
└── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner
└── role: gitea_runner
├── user_setup.yml (create per-runner system user + lingering)
├── rootless_docker.yml (rootless Docker setup under runner user)
├── install_runner.yml (download binary, config, register, service)
@@ -83,8 +83,8 @@ Each runner runs as a systemd user service under a dedicated system user (`grm-<
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
- **Home**: `/home/grm-<name>/`
- **Data**: `/var/lib/gitea-runner/<name>/`
- **Config**: `/etc/gitea-runner/<name>/`
- **Data**: `/var/lib/gitea_runner/<name>/`
- **Config**: `/etc/gitea_runner/<name>/`
- **Service**: `gitea-runner.service` (systemd user service)
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
- **subuid/subgid**: `grm-<name>:100000:65536` (user namespace mapping)
@@ -100,7 +100,7 @@ flowchart TD
EXEC["Executor<br/>executor.py"]
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
ANS["ansible-playbook subprocess"]
ROLE["Ansible Role<br/>ansible/roles/gitea-runner/"]
ROLE["Ansible Role<br/>ansible/roles/gitea_runner/"]
USER["user_setup.yml<br/>create system user + lingering"]
DOCKER["rootless_docker.yml<br/>rootless Docker setup"]
INSTALL["install_runner.yml<br/>download, config, register, service"]
+1 -1
View File
@@ -16,7 +16,7 @@
│ ├── ui.py # Colorised console output
│ └── translations.json # Translation strings
├── ansible/
│ ├── roles/gitea-runner/ # Main Ansible role
│ ├── roles/gitea_runner/ # Main Ansible role
│ │ ├── defaults/main.yml # Default variables
│ │ ├── tasks/ # Task files (13 files)
│ │ ├── templates/ # Jinja2 templates (4 files)
+2 -2
View File
@@ -43,11 +43,11 @@ make molecule # Quick: all 6 scenarios on Ubuntu 22.04
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
```
Molecule tests validate the Ansible role (`ansible/roles/gitea-runner/`) by creating Docker containers as test hosts, applying the role, and verifying the results. Each scenario tests a specific aspect of the role.
Molecule tests validate the Ansible role (`ansible/roles/gitea_runner/`) by creating Docker containers as test hosts, applying the role, and verifying the results. Each scenario tests a specific aspect of the role.
### Scenarios
Seven Molecule scenarios are defined under `ansible/roles/gitea-runner/molecule/`:
Seven Molecule scenarios are defined under `ansible/roles/gitea_runner/molecule/`:
| Scenario | Description | What it verifies |
|----------|-------------|------------------|
+1 -1
View File
@@ -335,7 +335,7 @@ grm health prod-runner
Output shows NAME, HOST, HEALTHY (yes/no), and MESSAGE columns. The command exits with code 1 if any runner is unhealthy.
The health check is also run automatically via a systemd timer installed by the Ansible role. See `ansible/roles/gitea-runner/templates/runner-healthcheck.sh.j2` for the script and `runner-healthcheck.timer.j2` for the timer.
The health check is also run automatically via a systemd timer installed by the Ansible role. See `ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2` for the script and `runner-healthcheck.timer.j2` for the timer.
## trigger-workflow
+6 -6
View File
@@ -32,19 +32,19 @@ version = {attr = "grm.__version__"}
ci = [
"pytest==9.1.1",
"pytest-cov==7.1.0",
"build==1.5.0",
"build==1.5.1",
"twine==6.2.0",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx==0.40.0",
"devx==0.42.0",
]
# Lint and type-checking tools (validate job)
lint = [
"ruff==0.15.20",
"ruff==0.15.21",
"pyright==1.1.411",
"bandit==1.9.4",
"pip-audit==2.10.1",
"pre-commit==4.6.0",
"ansible-lint==26.4.0",
"ansible-lint==26.6.0",
]
# Molecule testing (molecule-tests job — stays as a separate CI job)
molecule = [
@@ -55,7 +55,7 @@ molecule = [
dev = [
"grm[ci,lint,molecule]",
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
"devx==0.40.0",
"devx==0.42.0",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
@@ -108,7 +108,7 @@ repo_name = "grm"
ci_scripts_dir = ""
# Molecule test weights for LPT scheduling.
# GRM has a single role (gitea-runner) with 7 scenarios.
# GRM has a single role (gitea_runner) with 7 scenarios.
# Weights are estimates — recalibrate from CI logs after next run.
[tool.devx.molecule.weights]
"multi-instance" = 8
+1 -1
View File
@@ -157,7 +157,7 @@ class AnsibleExecutor:
with open(log_file) as f:
content = f.read()
match = re.search(
r'TASK \[gitea-runner : Report runner status\].*?"msg":\s*"([^"]+)"',
r'TASK \[gitea_runner : Report runner status\].*?"msg":\s*"([^"]+)"',
content,
re.DOTALL,
)
+9 -9
View File
@@ -100,14 +100,14 @@ class RunnerManager:
extra_vars: dict[str, str | int] = {
"registration_token": token,
"runner_name": name,
"gitea_runner_name": name,
"gitea_url": gitea_url,
"gitea_runner_integration_retries": integration_retries,
}
if admin_token:
extra_vars["gitea_admin_token"] = admin_token
if labels is not None:
extra_vars["runner_labels"] = labels
extra_vars["gitea_runner_labels"] = labels
with track_steps() as tracker:
tracker.begin(_("Installing Gitea Runner on {host}", host=host))
@@ -216,7 +216,7 @@ class RunnerManager:
"start-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -243,7 +243,7 @@ class RunnerManager:
"stop-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -270,7 +270,7 @@ class RunnerManager:
"restart-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -297,7 +297,7 @@ class RunnerManager:
"enable-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -331,7 +331,7 @@ class RunnerManager:
"disable-runner.yml",
actual_host,
actual_user,
{"runner_name": name, "registration_token": token, "gitea_url": resolved_gitea_url},
{"gitea_runner_name": name, "registration_token": token, "gitea_url": resolved_gitea_url},
actual_key,
ask_become_pass,
description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -358,7 +358,7 @@ class RunnerManager:
"status-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -401,7 +401,7 @@ class RunnerManager:
"remove-runner.yml",
actual_host,
actual_user,
{"runner_name": name, "registration_token": resolved_token, "gitea_url": resolved_gitea_url},
{"gitea_runner_name": name, "registration_token": resolved_token, "gitea_url": resolved_gitea_url},
actual_key,
ask_become_pass,
description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host),
+2 -2
View File
@@ -70,7 +70,7 @@ class TestAnsibleExecutorRun:
def test_run_extracts_status(self, tmp_path: Path) -> None:
log_content = (
"TASK [gitea-runner : Report runner status]\n"
"TASK [gitea_runner : Report runner status]\n"
"ok: [127.0.0.1] => {\n"
' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
"}\n"
@@ -89,7 +89,7 @@ class TestAnsibleExecutorExtractStatus:
def test_extract_status_found(self, tmp_path: Path) -> None:
executor = AnsibleExecutor()
log_content = (
"TASK [gitea-runner : Report runner status]\n"
"TASK [gitea_runner : Report runner status]\n"
"ok: [127.0.0.1] => {\n"
' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
"}\n"
+11 -11
View File
@@ -49,7 +49,7 @@ class TestRunnerManager:
assert "--extra-vars" in cmd_str
assert "@/tmp/fake-vars.json" in cmd_str
assert manager._captured_extra_vars["registration_token"] == "tok"
assert manager._captured_extra_vars["runner_name"] == "192.168.1.10"
assert manager._captured_extra_vars["gitea_runner_name"] == "192.168.1.10"
assert manager._captured_extra_vars["gitea_url"] == "https://git.example.com"
assert "Installing Gitea Runner on 192.168.1.10" in mock_executor.run.call_args.kwargs["description"]
mock_registry.add.assert_called_once_with(
@@ -75,7 +75,7 @@ class TestRunnerManager:
assert "--private-key" in cmd_str
assert "/key" in cmd_str
assert manager._captured_extra_vars["registration_token"] == "preset"
assert manager._captured_extra_vars["runner_name"] == "my-runner"
assert manager._captured_extra_vars["gitea_runner_name"] == "my-runner"
assert "--ask-become-pass" not in cmd_str
mock_registry.add.assert_called_once_with(
name="my-runner",
@@ -126,7 +126,7 @@ class TestRunnerManager:
gitea_url="https://git.example.com",
labels="docker:docker://alpine:latest",
)
assert manager._captured_extra_vars["runner_labels"] == "docker:docker://alpine:latest"
assert manager._captured_extra_vars["gitea_runner_labels"] == "docker:docker://alpine:latest"
def test_install_no_labels(self) -> None:
mock_registry = MagicMock()
@@ -145,7 +145,7 @@ class TestRunnerManager:
manager._executor = mock_executor
manager.install("host1", "root", token="tok", gitea_url="https://git.example.com", labels="")
assert manager._captured_extra_vars["runner_labels"] == ""
assert manager._captured_extra_vars["gitea_runner_labels"] == ""
def test_install_with_admin_token(self) -> None:
mock_registry = MagicMock()
@@ -265,7 +265,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "start-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Starting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_start_with_override(self) -> None:
@@ -304,7 +304,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "stop-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Stopping Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_restart(self) -> None:
@@ -318,7 +318,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "restart-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Restarting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_enable(self) -> None:
@@ -332,7 +332,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "enable-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Enabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_disable(self) -> None:
@@ -351,7 +351,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "disable-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert manager._captured_extra_vars["registration_token"] == "tok"
assert manager._captured_extra_vars["gitea_url"] == "https://git.example.com"
assert "Disabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
@@ -396,7 +396,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "status-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Checking status of Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_remove(self) -> None:
@@ -415,7 +415,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "remove-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert manager._captured_extra_vars["registration_token"] == "tok"
assert manager._captured_extra_vars["gitea_url"] == "https://git.example.com"
assert "Removing Gitea Runner r1 from host" in mock_executor.run.call_args.kwargs["description"]