refactor: rootless Docker, fix auto-merge, molecule platform matrix

Three major improvements:

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

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Emil Simeonov
2026-06-20 21:02:52 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 0c6c735000
commit 55c2746569
71 changed files with 2444 additions and 1277 deletions
+3
View File
@@ -5,3 +5,6 @@ exclude_paths:
- molecule/ - molecule/
- .molecule/ - .molecule/
- .pytest_cache/ - .pytest_cache/
skip_list:
# Rootless Docker uses `systemctl --user` which the systemd module doesn't support
- command-instead-of-module
+17 -7
View File
@@ -36,22 +36,32 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
run: make setup run: make setup
- name: Discover assigned scenarios - name: Discover assigned test pairs
run: | run: |
. .venv/bin/activate . .venv/bin/activate
SCENARIOS=$(python3 scripts/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners 3) PAIRS=$(python3 scripts/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners 3)
echo "Assigned scenarios: $SCENARIOS" echo "Assigned pairs: $PAIRS"
echo "SCENARIOS=$SCENARIOS" >> $GITHUB_ENV echo "TEST_PAIRS=$PAIRS" >> $GITHUB_ENV
- name: Run molecule tests - name: Run molecule tests
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export DOCKER_HOST="unix:///run/user/$(id -u)/docker.sock" export DOCKER_HOST="unix:///run/user/$(id -u)/docker.sock"
export ANSIBLE_INJECT_INVOCATION=1 export ANSIBLE_INJECT_INVOCATION=1
cd ansible/roles/gitea-runner cd ansible/roles/gitea-runner
for s in $SCENARIOS; do for pair in $TEST_PAIRS; do
if [ "$s" = "default" ]; then IFS='|' read -r scenario platform_name platform_image platform_command <<< "$pair"
export MOLECULE_PLATFORM_NAME="$platform_name"
export MOLECULE_PLATFORM_IMAGE="$platform_image"
if [ -n "$platform_command" ]; then
export MOLECULE_PLATFORM_COMMAND="$platform_command"
else
unset MOLECULE_PLATFORM_COMMAND
fi
echo "::group::Molecule: $scenario on $platform_name"
if [ "$scenario" = "default" ]; then
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true molecule test ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true molecule test
else else
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true molecule test -s "$s" ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true molecule test -s "$scenario"
fi fi
echo "::endgroup::"
done done
+1 -1
View File
@@ -1 +1 @@
3.11.11 3.12
+1 -1
View File
@@ -46,7 +46,7 @@ make test-all # Runs pytest-cov + molecule
make lint-all # Runs ruff, pyright, bandit, ansible-lint, checkmake make lint-all # Runs ruff, pyright, bandit, ansible-lint, checkmake
make lint-bandit # Security scan with bandit make lint-bandit # Security scan with bandit
make pytest-cov # Unit tests with 100% coverage enforcement make pytest-cov # Unit tests with 100% coverage enforcement
make molecule # All 7 molecule scenarios make molecule # All 6 molecule scenarios
``` ```
## Code Quality ## Code Quality
+16 -23
View File
@@ -1,4 +1,4 @@
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-docker molecule-binary molecule-all test-all clean .PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean
PYTHON := python3 PYTHON := python3
VENV := .venv VENV := .venv
@@ -17,7 +17,7 @@ setup: $(VENV)/bin/activate .env activate-scripts checkmake
fi fi
$(VENV)/bin/activate: $(VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 11), f'Python 3.11+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')" @python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(PYTHON) -m venv $(VENV) $(PYTHON) -m venv $(VENV)
$(BIN)/pip install --upgrade pip setuptools wheel $(BIN)/pip install --upgrade pip setuptools wheel
@@ -36,35 +36,35 @@ checkmake:
install: install:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm install $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(MODE),--mode $(MODE),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm install $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
update: update:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make update HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make update HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm update $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(VERSION),--version $(VERSION),) $(if $(MODE),--mode $(MODE),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm update $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(VERSION),--version $(VERSION),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
start: start:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make start HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make start HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm start $(HOST) $(if $(USER),--user $(USER),) $(if $(NAME),--name $(NAME),) $(if $(MODE),--mode $(MODE),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm start $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
stop: stop:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make stop HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make stop HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm stop $(HOST) $(if $(USER),--user $(USER),) $(if $(NAME),--name $(NAME),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm stop $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
enable: enable:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make enable HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make enable HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm enable $(HOST) $(if $(USER),--user $(USER),) $(if $(NAME),--name $(NAME),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm enable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
disable: disable:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make disable HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make disable HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm disable $(HOST) $(if $(USER),--user $(USER),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(MODE),--mode $(MODE),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm disable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
status: status:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make status HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make status HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm status $(HOST) $(if $(USER),--user $(USER),) $(if $(NAME),--name $(NAME),) $(if $(MODE),--mode $(MODE),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm status $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
remove: remove:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make remove HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make remove HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm remove $(HOST) $(if $(USER),--user $(USER),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(MODE),--mode $(MODE),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
lint-ruff: lint-ruff:
$(BIN)/ruff check src/ tests/ $(BIN)/ruff check src/ tests/
@@ -98,22 +98,15 @@ pytest-cov:
$(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100 $(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
MOLECULE := $(realpath $(BIN))/molecule MOLECULE := $(realpath $(BIN))/molecule
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true $(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 # Quick local test: Ubuntu 22.04 only, all scenarios
molecule: molecule:
@set -e; for s in default binary multi-instance lifecycle template-content deregister update; do if [ "$$s" = "default" ]; then $(MOLECULE_BASE) test; else $(MOLECULE_BASE) test -s $$s; fi; done @set -e; for s in default multi-instance lifecycle template-content deregister update; do if [ "$$s" = "default" ]; then $(MOLECULE_BASE) test; else $(MOLECULE_BASE) test -s $$s; fi; done
# Docker mode tests for all platforms (sequential; override env vars for CI matrix parallelisation) # All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
molecule-docker: molecule-all:
@set -e; for p in ubuntu-2204:geerlingguy/docker-ubuntu2204-ansible:latest:'' ubuntu-2404:geerlingguy/docker-ubuntu2404-ansible:latest:'' ubuntu-2604:geerlingguy/docker-ubuntu2604-ansible:latest:'' debian-12:geerlingguy/docker-debian12-ansible:latest:'' debian-13:geerlingguy/docker-debian13-ansible:latest:'' archlinux:marcstraube/archlinux-ansible:latest:/usr/lib/systemd/systemd; do IFS=":"; set -- $$p; MOLECULE_PLATFORM_NAME=$$1 MOLECULE_PLATFORM_IMAGE=$$2 MOLECULE_PLATFORM_COMMAND=$$3 $(MOLECULE_BASE) test; done @bash scripts/molecule_all.sh
# Binary mode tests for all platforms (sequential; override env vars for CI matrix parallelisation)
molecule-binary:
@set -e; for p in ubuntu-2204:geerlingguy/docker-ubuntu2204-ansible:latest:'' ubuntu-2404:geerlingguy/docker-ubuntu2404-ansible:latest:'' ubuntu-2604:geerlingguy/docker-ubuntu2604-ansible:latest:'' debian-12:geerlingguy/docker-debian12-ansible:latest:'' debian-13:geerlingguy/docker-debian13-ansible:latest:'' archlinux:marcstraube/archlinux-ansible:latest:/usr/lib/systemd/systemd; do IFS=":"; set -- $$p; MOLECULE_PLATFORM_NAME=$$1 MOLECULE_PLATFORM_IMAGE=$$2 MOLECULE_PLATFORM_COMMAND=$$3 $(MOLECULE_BASE) test -s binary; done
# All platform tests (sequential; use CI matrix for parallel execution)
molecule-all: molecule-docker molecule-binary
test: test-all test: test-all
+54 -60
View File
@@ -2,7 +2,7 @@
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts. A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
By default, runners are deployed as **Docker containers** using the official `gitea/runner` image. A traditional **binary** deployment mode is also available. Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts. The runner binary (`gitea_runner`) is installed directly and managed as a systemd user service.
> **Pronunciation note:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM" in Latin letters) — the Bulgarian word for **thunder**. Wherever there are clouds, there may be thunders. This is an open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian). > **Pronunciation note:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM" in Latin letters) — the Bulgarian word for **thunder**. Wherever there are clouds, there may be thunders. This is an open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
@@ -23,19 +23,22 @@ This project uses **conventional commits** and **GRM-N branch prefixes**. See [C
- **Flexible** — accepts a plain IP address or hostname, and allows specifying the SSH user and private key. - **Flexible** — accepts a plain IP address or hostname, and allows specifying the SSH user and private key.
- **Runner registry** — stores runner connection metadata locally after installation. Subsequent commands need only the runner name. - **Runner registry** — stores runner connection metadata locally after installation. Subsequent commands need only the runner name.
- **Lifecycle management** — start, stop, enable, disable, status, and remove runners via CLI. - **Lifecycle management** — start, stop, enable, disable, status, and remove runners via CLI.
- **Multi-instance** — run multiple isolated runners on the same host, each with its own data directory and systemd service. - **Multi-instance** — run multiple isolated runners on the same host, each with its own system user, rootless Docker daemon, data directory, and systemd user service.
- **Systemd-managed** — both Docker and binary modes run under systemd template units (`gitea-runner@<name>.service`). - **Rootless Docker** — each runner gets its own rootless Docker daemon, avoiding conflicts with the host's Docker installation and enabling true parallel execution.
- **Systemd-managed** — runners run as systemd user services (`gitea-runner.service`) under dedicated per-runner system users.
## Supported Operating Systems ## Supported Operating Systems
- Arch Linux - Arch Linux
- Ubuntu 22.04 / 24.04 / 26.04 - Ubuntu 22.04 / 24.04
- Debian 12 / 13 - Debian 12
All supported OSes are tested in CI via molecule scenarios on every PR.
## Prerequisites ## Prerequisites
- **SSH key authentication** — 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. - **SSH key authentication** — 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 install packages, create systemd services, and manage 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`. - **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`.
## Quick Start ## Quick Start
@@ -44,8 +47,8 @@ This project uses **conventional commits** and **GRM-N branch prefixes**. See [C
```bash ```bash
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
cd gitea-runner-manager cd gitea-runner-manager
pyenv install 3.11.11 pyenv install 3.12
pyenv local 3.11.11 pyenv local 3.12
make setup make setup
``` ```
@@ -65,7 +68,7 @@ cp .env.example .env
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking: Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration) 1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
2. **Container/service** is running (proves daemon is polling for jobs) 2. **Systemd user service** is active (proves daemon is polling for jobs)
API checks, if enabled, are purely informational and do not affect pass/fail. API checks, if enabled, are purely informational and do not affect pass/fail.
@@ -74,11 +77,7 @@ API checks, if enabled, are purely informational and do not affect pass/fail.
Using the CLI (you will be prompted for the sudo password by default): Using the CLI (you will be prompted for the sudo password by default):
```bash ```bash
# Docker mode (default) — deploys gitea_runner as a container
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
# Binary mode — downloads and installs the gitea_runner binary with systemd
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner --mode binary
``` ```
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines. > **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
@@ -86,16 +85,12 @@ grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runne
Using Make: Using Make:
```bash ```bash
# Docker mode (default)
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
# Binary mode
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner MODE=binary
``` ```
### Runner Registry ### Runner Registry
After installation, GRM stores each runner's connection details (host, user, SSH key, mode, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments: After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments:
```bash ```bash
# List all registered runners with live systemd status # List all registered runners with live systemd status
@@ -129,19 +124,19 @@ grm remove prod-runner --token <token>
You can override any stored value by passing the corresponding flag: You can override any stored value by passing the corresponding flag:
```bash ```bash
grm start prod-runner --host 192.168.1.11 --user root --mode binary grm start prod-runner --host 192.168.1.11 --user root
``` ```
> **Automation tip:** If the remote host has passwordless sudo configured, pass `--no-ask-become-pass`. > **Automation tip:** If the remote host has passwordless sudo configured, pass `--no-ask-become-pass`.
### Multiple Instances on the Same Host ### Multiple Instances on the Same Host
Each runner instance is fully isolated with its own data directory and systemd service: Each runner instance is fully isolated with its own system user, rootless Docker daemon, data directory, and systemd user service:
```bash ```bash
# Install two runners on the same host # Install two runners on the same host
grm install 192.168.1.10 --user ubuntu --name workflow-runner grm install 192.168.1.10 --user ubuntu --name workflow-runner
grm install 192.168.1.10 --user ubuntu --name build-runner --mode binary grm install 192.168.1.10 --user ubuntu --name build-runner
# Manage them independently by name # Manage them independently by name
grm stop workflow-runner grm stop workflow-runner
@@ -153,7 +148,7 @@ grm status build-runner
The installer performs an automated integration test that verifies: The installer performs an automated integration test that verifies:
1. **`.runner` file exists** with valid JSON containing `id`, `uuid`, `token`, `address` — this proves successful registration with Gitea 1. **`.runner` file exists** with valid JSON containing `id`, `uuid`, `token`, `address` — this proves successful registration with Gitea
2. **Container/service is running** — this proves the daemon is active and polling for jobs 2. **Systemd user service is active** — this proves the daemon is polling for jobs
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**. You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
@@ -174,11 +169,8 @@ GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
**Runner logs** (on the remote host): **Runner logs** (on the remote host):
```bash ```bash
# Binary mode logs (via systemd template unit) # Runner logs (via systemd user service)
sudo journalctl -u gitea-runner@<name> -f sudo -u grm-<name> journalctl --user -u gitea-runner -f
# Docker mode logs
docker logs gitea-runner-<name> -f
``` ```
The GRM application writes to two destinations: The GRM application writes to two destinations:
@@ -197,27 +189,28 @@ Console output is automatically colorised via ``click.echo``: operation headers
GRM consists of two layers: GRM consists of two layers:
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess. 1. **Python CLI** (`src/gitea_runner_manager/`) — 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 installs Docker, configures the runner (binary or container), creates systemd template units, 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.
``` ```
grm install <host> grm install <host>
└── RunnerManager.install() └── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml └── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner └── role: gitea-runner
├── docker.yml (Docker installation) ├── user_setup.yml (create per-runner system user + lingering)
├── docker_mode.yml (container deployment) ├── rootless_docker.yml (rootless Docker setup under runner user)
├── binary_mode.yml (binary + systemd deployment) ├── install_runner.yml (download binary, config, register, service)
├── service.yml (systemd template unit) ├── prune.yml (Docker prune timer)
├── deregister.yml (deregistration from Gitea) └── integration_test.yml (validate service is active)
├── prune.yml (Docker prune timer)
└── integration_test.yml (validate online status)
``` ```
Both Docker and binary modes run under a single systemd template unit (`gitea-runner@.service`), instantiated per runner name (e.g., `gitea-runner@prod-runner.service`). Each instance has fully isolated directories: Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
- **Home**: `/home/grm-<name>/`
- **Data**: `/var/lib/gitea-runner/<name>/` - **Data**: `/var/lib/gitea-runner/<name>/`
- **Config**: `/etc/gitea-runner/<name>/` (binary mode) - **Config**: `/etc/gitea-runner/<name>/`
- **Service**: `gitea-runner@<name>.service` - **Service**: `gitea-runner.service` (systemd user service)
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
## Configuration ## Configuration
@@ -225,11 +218,13 @@ All tunable values are exposed as Ansible variables in `ansible/roles/gitea-runn
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `runner_mode` | `docker` | Deployment mode: `docker` or `binary` | | `gitea_runner_version` | `1.0.8` | Runner binary version |
| `gitea_runner_version` | `1.0.8` | Docker image tag / binary version |
| `gitea_runner_docker_image` | `gitea/runner` | Docker image name |
| `runner_labels` | `ubuntu-latest:docker://runner-images:ubuntu-22.04` | Runner labels | | `runner_labels` | `ubuntu-latest:docker://runner-images:ubuntu-22.04` | Runner labels |
| `skip_runner_registration` | `false` | Skip API registration (useful for tests) | | `skip_runner_registration` | `false` | Skip API registration (useful for tests) |
| `gitea_runner_user_prefix` | `grm-` | Prefix for per-runner system users |
| `gitea_runner_base_home` | `/home` | Base directory for runner user homes |
| `gitea_runner_service_user` | `{{ prefix }}{{ runner_name }}` | Per-runner system user |
| `gitea_runner_home` | `{{ base_home }}/{{ service_user }}` | Runner user home directory |
| `gitea_runner_base_data_dir` | `/var/lib/gitea-runner` | Base data directory (instance-scoped) | | `gitea_runner_base_data_dir` | `/var/lib/gitea-runner` | Base data directory (instance-scoped) |
| `gitea_runner_base_config_dir` | `/etc/gitea-runner` | Base config directory (instance-scoped) | | `gitea_runner_base_config_dir` | `/etc/gitea-runner` | Base config directory (instance-scoped) |
| `gitea_runner_data_dir` | `{{ base }}/{{ runner_name }}` | Runtime data directory per instance | | `gitea_runner_data_dir` | `{{ base }}/{{ runner_name }}` | Runtime data directory per instance |
@@ -239,7 +234,6 @@ All tunable values are exposed as Ansible variables in `ansible/roles/gitea-runn
| `gitea_runner_prune_schedule` | `daily` | systemd timer schedule | | `gitea_runner_prune_schedule` | `daily` | systemd timer schedule |
| `gitea_runner_prune_label` | `gitea-runner=true` | Docker label for pruning | | `gitea_runner_prune_label` | `gitea-runner=true` | Docker label for pruning |
| `gitea_runner_service_restart_sec` | `5` | systemd RestartSec value | | `gitea_runner_service_restart_sec` | `5` | systemd RestartSec value |
| `gitea_runner_service_user` | `{{ ansible_user \| default('root') }}` | Service user |
| `gitea_runner_log_level` | `info` | Runner log level | | `gitea_runner_log_level` | `info` | Runner log level |
| `gitea_runner_container_label` | `gitea-runner=true` | Container label | | `gitea_runner_container_label` | `gitea-runner=true` | Container label |
| `docker_gpg_key_path` | `/etc/apt/keyrings/docker.asc` | Docker GPG key path | | `docker_gpg_key_path` | `/etc/apt/keyrings/docker.asc` | Docker GPG key path |
@@ -311,18 +305,23 @@ Runs pytest with 100% coverage requirement.
### Molecule Tests ### Molecule Tests
```bash ```bash
make molecule make molecule # Quick: all 6 scenarios on Ubuntu 22.04
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
``` ```
Runs four scenarios: Runs six scenarios:
- **default** — Docker mode installation in an Ubuntu 22.04 container - **default** — Rootless Docker runner installation
- **binary** — Binary mode installation in an Ubuntu 22.04 container
- **multi-instance** — Two isolated runner instances on the same host - **multi-instance** — Two isolated runner instances on the same host
- **lifecycle** — Stop, disable, re-enable, and start sequence - **lifecycle** — Stop, disable, re-enable, and start sequence
- **template-content** — Verify rendered systemd user service and prune templates
- **deregister** — Runner deregistration
- **update** — Runner binary update
All scenarios test idempotence (second run produces zero changes). All scenarios test idempotence (second run produces zero changes).
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
### Integration Tests ### Integration Tests
```bash ```bash
@@ -346,7 +345,7 @@ This is a harmless cleanup traceback from Molecule's Docker driver when the test
### Runner appears offline after installation ### Runner appears offline after installation
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct. - Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
- Verify the runner container or service is running: `docker ps` or `systemctl status gitea-runner@<name>`. - Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
- Check logs for registration errors. - Check logs for registration errors.
### Integration test fails ### Integration test fails
@@ -358,22 +357,17 @@ The test checks two things:
- Runner logs for registration errors - Runner logs for registration errors
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner` - The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
2. **Container/service not running** — Daemon failed to start. Check: 2. **Service not running** — Daemon failed to start. Check:
- `docker ps` or `systemctl status gitea-runner@<name>` - `sudo -u grm-<name> systemctl --user status gitea-runner`
- Logs for connection errors - Logs for connection errors
### Docker mode: container won't start ### Rootless Docker: service fails to start
- Ensure Docker is installed and running on the host. - Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
- Verify the Docker socket is accessible: `docker version`. - Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
- Check systemd status: `systemctl status gitea-runner@<name>`. - Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
### Binary mode: systemd service fails - Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`.
- Check the service status: `systemctl status gitea-runner@<name>`.
- Verify the binary exists at `gitea_runner_binary_path`.
- Ensure the service user is in the `docker` group.
- Check logs: `journalctl -u gitea-runner@<name> -f`.
## Makefile Targets ## Makefile Targets
+14 -10
View File
@@ -9,12 +9,14 @@
name: gitea-runner name: gitea-runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Stop gitea-runner systemd instance - name: Stop gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: systemctl --user stop gitea-runner
name: "gitea-runner@{{ runner_name }}" become: true
state: stopped become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
daemon_reload: true environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
changed_when: true
- name: Include deregistration - name: Include deregistration
ansible.builtin.include_role: ansible.builtin.include_role:
@@ -22,9 +24,11 @@
tasks_from: deregister.yml tasks_from: deregister.yml
when: not skip_runner_registration | default(false) when: not skip_runner_registration | default(false)
- name: Disable gitea-runner systemd instance - name: Disable gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: systemctl --user disable gitea-runner
name: "gitea-runner@{{ runner_name }}" become: true
enabled: false become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
daemon_reload: true environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
changed_when: true
+16 -5
View File
@@ -9,9 +9,20 @@
name: gitea-runner name: gitea-runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Enable gitea-runner systemd instance - name: Enable gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: systemctl --user enable gitea-runner
name: "gitea-runner@{{ runner_name }}" become: true
enabled: true become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
daemon_reload: true environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
changed_when: true
- 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) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
+2 -3
View File
@@ -6,9 +6,8 @@
# ansible_ssh_private_key_file — Path to SSH private key # ansible_ssh_private_key_file — Path to SSH private key
# #
# Optional variables per host: # Optional variables per host:
# runner_mode=docker — Deployment mode: docker or binary # gitea_runner_version=1.0.8 — Runner binary version
# gitea_runner_version=1.0.8 — Runner image/binary version
[runners] [runners]
192.168.1.10 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519 192.168.1.10 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519
runner.example.com ansible_user=arch ansible_ssh_private_key_file=~/.ssh/id_ed25519 runner_mode=binary runner.example.com ansible_user=arch ansible_ssh_private_key_file=~/.ssh/id_ed25519
+52 -20
View File
@@ -9,31 +9,45 @@
name: gitea-runner name: gitea-runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Stop gitea-runner systemd instance - name: Get runner user UID
ansible.builtin.systemd: ansible.builtin.command: id -u "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
name: "gitea-runner-{{ runner_mode | default('docker') }}@{{ runner_name }}" register: runner_uid_result
state: stopped changed_when: false
daemon_reload: true failed_when: false
when: systemd_available.stat.exists
- name: Disable gitea-runner systemd instance - name: Set runner UID fact
ansible.builtin.systemd: ansible.builtin.set_fact:
name: "gitea-runner-{{ runner_mode | default('docker') }}@{{ runner_name }}" gitea_runner_uid: "{{ runner_uid_result.stdout }}"
enabled: false when: runner_uid_result.rc == 0
daemon_reload: true
when: systemd_available.stat.exists
- name: Stop Docker container directly - name: Stop gitea-runner user service
ansible.builtin.command: "docker stop -t 30 gitea-runner-{{ runner_name }}" ansible.builtin.command: systemctl --user stop gitea-runner
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 changed_when: true
failed_when: false failed_when: false
when: runner_mode | default('docker') == 'docker'
- name: Remove Docker container directly - name: Disable gitea-runner user service
ansible.builtin.command: "docker rm -f gitea-runner-{{ runner_name }}" ansible.builtin.command: systemctl --user disable gitea-runner
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: Stop rootless Docker daemon
ansible.builtin.command: systemctl --user stop docker
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
changed_when: true changed_when: true
failed_when: false failed_when: false
when: runner_mode | default('docker') == 'docker'
- name: Include deregistration - name: Include deregistration
ansible.builtin.include_role: ansible.builtin.include_role:
@@ -41,12 +55,30 @@
tasks_from: deregister.yml tasks_from: deregister.yml
when: not skip_runner_registration | default(false) when: not skip_runner_registration | default(false)
- name: Remove systemd unit file (last instance) - name: Remove systemd user unit file
ansible.builtin.file: ansible.builtin.file:
path: "/etc/systemd/system/gitea-runner-{{ runner_mode | default('docker') }}@.service" path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/gitea-runner.service"
state: absent state: absent
when: remove_systemd_template | default(false) when: remove_systemd_template | default(false)
- name: Kill remaining processes of runner user
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ 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) }}"
failed_when: false
changed_when: false
- name: Remove runner user and home directory
ansible.builtin.user:
name: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
state: absent
remove: true
when: remove_runner_user | default(true)
failed_when: false
- name: Remove runner data directory - name: Remove runner data directory
ansible.builtin.file: 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/' ~ runner_name) }}"
+8 -5
View File
@@ -1,10 +1,14 @@
--- ---
runner_mode: "docker"
gitea_runner_version: "1.0.8" gitea_runner_version: "1.0.8"
gitea_runner_docker_image: "gitea/runner" runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-22.04"
runner_labels: "ubuntu-latest:docker://runner-images:ubuntu-22.04"
skip_runner_registration: false skip_runner_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_home: "{{ gitea_runner_base_home }}/{{ gitea_runner_service_user }}"
# Base paths (instance-scoped via runner_name) # Base paths (instance-scoped via runner_name)
gitea_runner_base_data_dir: "/var/lib/gitea-runner" gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner" gitea_runner_base_config_dir: "/etc/gitea-runner"
@@ -19,12 +23,11 @@ gitea_runner_prune_label: "gitea-runner=true"
# Service configuration # Service configuration
gitea_runner_service_restart_sec: "5" gitea_runner_service_restart_sec: "5"
gitea_runner_service_user: "{{ ansible_user | default('root') }}"
# Runner configuration # Runner configuration
gitea_runner_log_level: "info" gitea_runner_log_level: "info"
gitea_runner_container_label: "gitea-runner=true" gitea_runner_container_label: "gitea-runner=true"
gitea_runner_file: ".runner" gitea_runner_file: ".runner"
# Docker installation # Docker installation (for rootless dependencies)
docker_gpg_key_path: "/etc/apt/keyrings/docker.asc" docker_gpg_key_path: "/etc/apt/keyrings/docker.asc"
+6 -3
View File
@@ -5,7 +5,10 @@
when: ansible_facts is defined and ansible_facts['service_mgr'] | default('') == 'systemd' when: ansible_facts is defined and ansible_facts['service_mgr'] | default('') == 'systemd'
- name: Restart gitea-runner - name: Restart gitea-runner
ansible.builtin.systemd: ansible.builtin.command: systemctl --user restart gitea-runner
name: "gitea-runner-{{ runner_mode }}@{{ runner_name }}" become: true
state: restarted become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: ansible_facts is defined and ansible_facts['service_mgr'] | default('') == 'systemd' when: ansible_facts is defined and ansible_facts['service_mgr'] | default('') == 'systemd'
@@ -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"
runner_mode: "binary"
skip_runner_registration: true
roles:
- role: gitea-runner
@@ -1,39 +0,0 @@
---
driver:
name: docker
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: false
provisioner:
name: ansible
playbooks:
converge: converge.yml
prepare: ../common/prepare.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -1,72 +0,0 @@
---
- name: Verify
hosts: all
become: true
vars:
runner_name: "molecule-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
- name: Override runner mode for binary scenario
ansible.builtin.set_fact:
runner_mode: "binary"
tasks:
- name: Check gitea_runner binary exists
ansible.builtin.stat:
path: "{{ gitea_runner_binary_path }}"
register: gitea_runner_stat
- name: Assert gitea_runner binary exists
ansible.builtin.assert:
that:
- gitea_runner_stat.stat.exists
fail_msg: "gitea_runner binary is missing at {{ gitea_runner_binary_path }}"
- name: Check Docker is installed
ansible.builtin.command: docker --version
changed_when: false
- name: Check systemd template unit exists
ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-{{ runner_mode | default('docker') }}@.service"
register: service_stat
- name: Assert template unit exists
ansible.builtin.assert:
that:
- service_stat.stat.exists
fail_msg: "Systemd template unit is missing"
- name: Check instance data directory exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}"
register: data_dir_stat
- name: Assert instance data directory exists
ansible.builtin.assert:
that:
- data_dir_stat.stat.exists
fail_msg: "Instance data directory is missing"
- name: Check prune timer exists
ansible.builtin.stat:
path: /etc/systemd/system/docker-prune.timer
register: timer_stat
- name: Assert prune timer exists
ansible.builtin.assert:
that:
- timer_stat.stat.exists
fail_msg: "Docker prune timer is missing"
- name: Check config file exists
ansible.builtin.stat:
path: "{{ gitea_runner_config_dir }}/config.yaml"
register: config_stat
- name: Assert config file exists
ansible.builtin.assert:
that:
- config_stat.stat.exists
fail_msg: "Config file is missing"
@@ -19,16 +19,3 @@
community.general.pacman: community.general.pacman:
update_cache: true update_cache: true
when: ansible_facts['os_family'] == 'Archlinux' when: ansible_facts['os_family'] == 'Archlinux'
- name: Ensure /etc/docker directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
mode: "0755"
- name: Configure Docker daemon for vfs storage driver (DinD)
ansible.builtin.copy:
dest: /etc/docker/daemon.json
content: |
{"storage-driver": "vfs"}
mode: "0644"
@@ -9,42 +9,39 @@
ansible.builtin.include_vars: ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults" dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks: tasks:
- name: Check Docker is installed - name: Check runner user exists
ansible.builtin.command: docker --version ansible.builtin.user:
changed_when: false name: "{{ gitea_runner_service_user }}"
register: user_info
check_mode: true
- name: Check runner image was pulled - name: Assert runner user exists
ansible.builtin.command: docker images gitea/runner --format '{{ '{{' }} .Repository {{ '}}' }}:{{ '{{' }} .Tag {{ '}}' }}'
register: image_list
changed_when: false
- name: Assert runner image exists locally
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- "'gitea/runner' in image_list.stdout" - user_info.state == "present"
fail_msg: "Runner Docker image was not pulled" fail_msg: "Runner system user was not created"
- name: Check prune timer exists - name: Check runner binary exists
ansible.builtin.stat: ansible.builtin.stat:
path: /etc/systemd/system/docker-prune.timer path: "{{ gitea_runner_binary_path }}"
register: timer_stat register: binary_stat
- name: Assert prune timer exists - name: Assert runner binary exists
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- timer_stat.stat.exists - binary_stat.stat.exists
fail_msg: "Docker prune timer is missing" fail_msg: "Gitea runner binary is missing"
- name: Check systemd template unit exists - name: Check systemd user service exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-{{ runner_mode | default('docker') }}@.service" path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat register: service_stat
- name: Assert template unit exists - name: Assert user service exists
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- service_stat.stat.exists - service_stat.stat.exists
fail_msg: "Systemd template unit is missing" fail_msg: "Systemd user service is missing"
- name: Check instance data directory exists - name: Check instance data directory exists
ansible.builtin.stat: ansible.builtin.stat:
@@ -57,9 +54,9 @@
- data_dir_stat.stat.exists - data_dir_stat.stat.exists
fail_msg: "Instance data directory is missing" fail_msg: "Instance data directory is missing"
- name: Check config file exists in data directory - name: Check config file exists in config directory
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/config.yaml" path: "{{ gitea_runner_config_dir }}/config.yaml"
register: config_stat register: config_stat
- name: Assert config file exists - name: Assert config file exists
@@ -67,3 +64,14 @@
that: that:
- config_stat.stat.exists - config_stat.stat.exists
fail_msg: "Config file is missing" fail_msg: "Config file is missing"
- name: Check prune timer exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
register: timer_stat
- name: Assert prune timer exists
ansible.builtin.assert:
that:
- timer_stat.stat.exists
fail_msg: "Docker prune timer is missing"
@@ -4,12 +4,18 @@
become: true become: true
vars: vars:
runner_name: "deregister-test-runner" runner_name: "deregister-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks: tasks:
- name: Ensure fake .runner file exists - name: Ensure fake .runner file exists
ansible.builtin.copy: ansible.builtin.copy:
dest: "/var/lib/gitea-runner/{{ runner_name }}/.runner" dest: "{{ gitea_runner_data_dir }}/.runner"
content: | content: |
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ runner_name }}", "address": "http://localhost:3000"} {"id": 1, "uuid": "test-uuid-1234", "name": "{{ runner_name }}", "address": "http://localhost:3000"}
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644" mode: "0644"
- name: Deregister runner - name: Deregister runner
@@ -19,7 +25,6 @@
runner_name: "deregister-test-runner" runner_name: "deregister-test-runner"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
runner_mode: "docker"
skip_runner_registration: false skip_runner_registration: false
tasks: tasks:
- name: Include deregistration tasks - name: Include deregistration tasks
@@ -9,7 +9,7 @@
tasks: tasks:
- name: Check registration file was removed - name: Check registration file was removed
ansible.builtin.stat: ansible.builtin.stat:
path: "/var/lib/gitea-runner/deregister-test-runner/.runner" path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat register: runner_file_stat
- name: Assert registration file no longer exists - name: Assert registration file no longer exists
@@ -18,13 +18,13 @@
- not runner_file_stat.stat.exists - not runner_file_stat.stat.exists
fail_msg: "Registration file (.runner) was not removed by deregistration" fail_msg: "Registration file (.runner) was not removed by deregistration"
- name: Check systemd template still exists - name: Check systemd user service still exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-{{ runner_mode | default('docker') }}@.service" path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat register: service_stat
- name: Assert systemd template still exists after deregister - name: Assert user service still exists after deregister
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- service_stat.stat.exists - service_stat.stat.exists
fail_msg: "Systemd template unit was incorrectly removed" fail_msg: "Systemd user service was incorrectly removed"
@@ -5,24 +5,17 @@
vars: vars:
runner_name: "lifecycle-test-runner" runner_name: "lifecycle-test-runner"
pre_tasks: pre_tasks:
- name: Check if systemd is available - name: Load role defaults
ansible.builtin.stat: ansible.builtin.include_vars:
path: /run/systemd/system dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
register: systemd_available
tasks: tasks:
- name: Stop gitea-runner systemd instance - name: Stop gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user stop gitea-runner"
name: "gitea-runner-docker@{{ runner_name }}" changed_when: true
state: stopped
daemon_reload: true
when: systemd_available.stat.exists
- name: Disable gitea-runner systemd instance - name: Disable gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user disable gitea-runner"
name: "gitea-runner-docker@{{ runner_name }}" changed_when: true
enabled: false
daemon_reload: true
when: systemd_available.stat.exists
- name: Re-enable and start runner - name: Re-enable and start runner
hosts: all hosts: all
@@ -30,21 +23,14 @@
vars: vars:
runner_name: "lifecycle-test-runner" runner_name: "lifecycle-test-runner"
pre_tasks: pre_tasks:
- name: Check if systemd is available - name: Load role defaults
ansible.builtin.stat: ansible.builtin.include_vars:
path: /run/systemd/system dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
register: systemd_available
tasks: tasks:
- name: Enable gitea-runner systemd instance - name: Enable gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user enable gitea-runner"
name: "gitea-runner-docker@{{ runner_name }}" changed_when: true
enabled: true
daemon_reload: true
when: systemd_available.stat.exists
- name: Start gitea-runner systemd instance - name: Start gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user start gitea-runner"
name: "gitea-runner-docker@{{ runner_name }}" changed_when: true
state: started
daemon_reload: true
when: systemd_available.stat.exists
@@ -9,38 +9,20 @@
ansible.builtin.include_vars: ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults" dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks: tasks:
- name: Check systemd template unit exists - name: Check systemd user service exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-{{ runner_mode | default('docker') }}@.service" path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: template_stat register: service_stat
- name: Assert template unit exists - name: Assert user service exists
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- template_stat.stat.exists - service_stat.stat.exists
fail_msg: "Systemd template unit is missing" fail_msg: "Systemd user service is missing"
- name: Check if systemd is available
ansible.builtin.stat:
path: /run/systemd/system
register: systemd_available
- name: Check instance is enabled
ansible.builtin.systemd:
name: "gitea-runner-{{ runner_mode | default('docker') }}@lifecycle-test-runner"
register: service_status
when: systemd_available.stat.exists
- name: Assert instance is enabled
ansible.builtin.assert:
that:
- service_status.status.LoadState == 'loaded'
fail_msg: "Systemd instance is not loaded"
when: systemd_available.stat.exists
- name: Check instance data directory exists after lifecycle - name: Check instance data directory exists after lifecycle
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}" path: "{{ gitea_runner_data_dir }}"
register: data_dir_stat register: data_dir_stat
- name: Assert instance data directory exists - name: Assert instance data directory exists
@@ -6,7 +6,6 @@
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-a" runner_name: "molecule-runner-a"
runner_mode: "binary"
skip_runner_registration: true skip_runner_registration: true
roles: roles:
- role: gitea-runner - role: gitea-runner
@@ -18,7 +17,6 @@
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-b" runner_name: "molecule-runner-b"
runner_mode: "binary"
skip_runner_registration: true skip_runner_registration: true
roles: roles:
- role: gitea-runner - role: gitea-runner
@@ -6,24 +6,32 @@
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults" dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
- name: Override runner mode for multi-instance scenario
ansible.builtin.set_fact:
runner_mode: "binary"
tasks: tasks:
- name: Check systemd template unit exists - name: Check first runner user exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-{{ runner_mode | default('docker') }}@.service" path: "{{ gitea_runner_base_home }}/grm-molecule-runner-a"
register: template_stat register: home_a_stat
- name: Assert template unit exists - name: Assert first runner user home exists
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- template_stat.stat.exists - home_a_stat.stat.exists
fail_msg: "Systemd template unit is missing" fail_msg: "First runner user home is missing"
- name: Check second runner user exists
ansible.builtin.stat:
path: "{{ gitea_runner_base_home }}/grm-molecule-runner-b"
register: home_b_stat
- name: Assert second runner user home exists
ansible.builtin.assert:
that:
- home_b_stat.stat.exists
fail_msg: "Second runner user home is missing"
- name: Check first instance data directory exists - name: Check first instance data directory exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/var/lib/gitea-runner/molecule-runner-a" path: "{{ gitea_runner_base_data_dir }}/molecule-runner-a"
register: data_a_stat register: data_a_stat
- name: Assert first instance data directory exists - name: Assert first instance data directory exists
@@ -34,7 +42,7 @@
- name: Check second instance data directory exists - name: Check second instance data directory exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/var/lib/gitea-runner/molecule-runner-b" path: "{{ gitea_runner_base_data_dir }}/molecule-runner-b"
register: data_b_stat register: data_b_stat
- name: Assert second instance data directory exists - name: Assert second instance data directory exists
@@ -45,7 +53,7 @@
- name: Check first instance config exists - name: Check first instance config exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/gitea-runner/molecule-runner-a/config.yaml" path: "{{ gitea_runner_base_config_dir }}/molecule-runner-a/config.yaml"
register: config_a_stat register: config_a_stat
- name: Assert first instance config exists - name: Assert first instance config exists
@@ -56,7 +64,7 @@
- name: Check second instance config exists - name: Check second instance config exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/gitea-runner/molecule-runner-b/config.yaml" path: "{{ gitea_runner_base_config_dir }}/molecule-runner-b/config.yaml"
register: config_b_stat register: config_b_stat
- name: Assert second instance config exists - name: Assert second instance config exists
@@ -1,23 +1,11 @@
--- ---
- name: Converge Docker mode - name: Converge
hosts: all hosts: all
become: true become: true
vars: vars:
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
runner_name: "template-docker-runner" runner_name: "template-test-runner"
skip_runner_registration: true
roles:
- role: gitea-runner
- name: Converge Binary mode
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "template-binary-runner"
runner_mode: "binary"
skip_runner_registration: true skip_runner_registration: true
roles: roles:
- role: gitea-runner - role: gitea-runner
@@ -2,65 +2,42 @@
- name: Verify - name: Verify
hosts: all hosts: all
become: true become: true
vars:
runner_name: "template-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults" dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks: tasks:
- name: Check Docker mode systemd template exists - name: Check systemd user service exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-docker@.service" path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: docker_template_stat register: service_stat
- name: Assert Docker mode systemd template exists - name: Assert user service exists
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- docker_template_stat.stat.exists - service_stat.stat.exists
fail_msg: "Docker systemd template is missing" fail_msg: "Systemd user service is missing"
- name: Read rendered Docker systemd template - name: Read rendered user service template
ansible.builtin.slurp: ansible.builtin.slurp:
src: "/etc/systemd/system/gitea-runner-docker@.service" src: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: docker_template register: service_template
- name: Assert Docker mode template contains expected directives - name: Assert user service template contains expected directives
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- "'Type=oneshot' in docker_template.content | b64decode" - "'Type=simple' in service_template.content | b64decode"
- "'RemainAfterExit=yes' in docker_template.content | b64decode" - "'ExecStart={{ gitea_runner_binary_path }}' in service_template.content | b64decode"
- "'Requires=docker.service' in docker_template.content | b64decode" - "'Restart=on-failure' in service_template.content | b64decode"
- "'docker run -d' in docker_template.content | b64decode" - "'DOCKER_HOST=unix:///run/user' in service_template.content | b64decode"
- "'ExecStop=/usr/bin/docker stop' in docker_template.content | b64decode" - "'XDG_RUNTIME_DIR=/run/user' in service_template.content | b64decode"
- "'Restart=on-failure' in docker_template.content | b64decode" fail_msg: "User service template is missing expected directives"
fail_msg: "Docker systemd template is missing expected directives"
- name: Check Binary mode systemd template exists
ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-binary@.service"
register: binary_template_stat
- name: Assert Binary mode systemd template exists
ansible.builtin.assert:
that:
- binary_template_stat.stat.exists
fail_msg: "Binary systemd template is missing"
- name: Read rendered Binary systemd template
ansible.builtin.slurp:
src: "/etc/systemd/system/gitea-runner-binary@.service"
register: binary_template
- name: Assert Binary mode template contains expected directives
ansible.builtin.assert:
that:
- "'Type=simple' in binary_template.content | b64decode"
- "'ExecStart={{ gitea_runner_binary_path }}' in binary_template.content | b64decode"
- "'Restart=on-failure' in binary_template.content | b64decode"
fail_msg: "Binary systemd template is missing expected directives"
- name: Read rendered prune service template - name: Read rendered prune service template
ansible.builtin.slurp: ansible.builtin.slurp:
src: "/etc/systemd/system/docker-prune.service" src: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.service"
register: prune_service register: prune_service
- name: Assert prune service contains expected directives - name: Assert prune service contains expected directives
@@ -69,17 +46,16 @@
- "'Type=oneshot' in prune_service.content | b64decode" - "'Type=oneshot' in prune_service.content | b64decode"
- "'docker system prune' in prune_service.content | b64decode" - "'docker system prune' in prune_service.content | b64decode"
- "'docker volume prune' in prune_service.content | b64decode" - "'docker volume prune' in prune_service.content | b64decode"
- "'label=gitea-runner=true' in prune_service.content | b64decode"
fail_msg: "Prune service template is missing expected directives" fail_msg: "Prune service template is missing expected directives"
- name: Read rendered prune timer template - name: Read rendered prune timer template
ansible.builtin.slurp: ansible.builtin.slurp:
src: "/etc/systemd/system/docker-prune.timer" src: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
register: prune_timer register: prune_timer
- name: Assert prune timer contains expected directives - name: Assert prune timer contains expected directives
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- "'OnCalendar=daily' in prune_timer.content | b64decode" - "'OnCalendar={{ gitea_runner_prune_schedule }}' in prune_timer.content | b64decode"
- "'Persistent=true' in prune_timer.content | b64decode" - "'Persistent=true' in prune_timer.content | b64decode"
fail_msg: "Prune timer template is missing expected directives" fail_msg: "Prune timer template is missing expected directives"
@@ -1,23 +1,11 @@
--- ---
- name: Converge Docker mode - name: Converge
hosts: all hosts: all
become: true become: true
vars: vars:
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
runner_name: "update-docker-runner" runner_name: "update-test-runner"
skip_runner_registration: true
roles:
- role: gitea-runner
- name: Converge Binary mode
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "update-binary-runner"
runner_mode: "binary"
skip_runner_registration: true skip_runner_registration: true
roles: roles:
- role: gitea-runner - role: gitea-runner
@@ -1,24 +1,11 @@
--- ---
- name: Update Docker mode runner - name: Update runner
hosts: all hosts: all
become: true become: true
vars: vars:
runner_name: "update-docker-runner" runner_name: "update-test-runner"
runner_mode: "docker"
tasks: tasks:
- name: Include Docker update tasks - name: Include update tasks
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea-runner
tasks_from: docker_update.yml tasks_from: update_runner.yml
- name: Update Binary mode runner
hosts: all
become: true
vars:
runner_name: "update-binary-runner"
runner_mode: "binary"
tasks:
- name: Include binary update tasks
ansible.builtin.include_role:
name: gitea-runner
tasks_from: binary_update.yml
@@ -2,23 +2,14 @@
- name: Verify - name: Verify
hosts: all hosts: all
become: true become: true
vars:
runner_name: "update-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults" dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks: tasks:
- name: Check Docker runner image still exists after update - name: Check runner binary still exists after update
ansible.builtin.command: docker images gitea/runner --format '{{ '{{' }} .Repository {{ '}}' }}:{{ '{{' }} .Tag {{ '}}' }}'
register: image_list
changed_when: false
- name: Assert runner image exists after update
ansible.builtin.assert:
that:
- "'gitea/runner' in image_list.stdout"
fail_msg: "Runner Docker image missing after update"
- name: Check binary runner executable still exists after update
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ gitea_runner_binary_path }}" path: "{{ gitea_runner_binary_path }}"
register: binary_stat register: binary_stat
@@ -27,48 +18,26 @@
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- binary_stat.stat.exists - binary_stat.stat.exists
fail_msg: "Binary runner executable missing after update" fail_msg: "Runner binary missing after update"
- name: Check Docker mode systemd template still exists - name: Check systemd user service still exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-docker@.service" path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: docker_service_stat register: service_stat
- name: Assert Docker mode systemd template still exists after update - name: Assert user service exists after update
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- docker_service_stat.stat.exists - service_stat.stat.exists
fail_msg: "Docker mode systemd template missing after update" fail_msg: "Systemd user service missing after update"
- name: Check Binary mode systemd template still exists - name: Check instance data directory still exists
ansible.builtin.stat: ansible.builtin.stat:
path: "/etc/systemd/system/gitea-runner-binary@.service" path: "{{ gitea_runner_data_dir }}"
register: binary_service_stat register: data_stat
- name: Assert Binary mode systemd template still exists after update - name: Assert data directory exists after update
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- binary_service_stat.stat.exists - data_stat.stat.exists
fail_msg: "Binary mode systemd template missing after update" fail_msg: "Runner data directory missing after update"
- name: Check Docker mode data directory still exists
ansible.builtin.stat:
path: "/var/lib/gitea-runner/update-docker-runner"
register: docker_data_stat
- name: Assert Docker mode data directory exists after update
ansible.builtin.assert:
that:
- docker_data_stat.stat.exists
fail_msg: "Docker runner data directory missing after update"
- name: Check Binary mode data directory still exists
ansible.builtin.stat:
path: "/var/lib/gitea-runner/update-binary-runner"
register: binary_data_stat
- name: Assert Binary mode data directory exists after update
ansible.builtin.assert:
that:
- binary_data_stat.stat.exists
fail_msg: "Binary runner data directory missing after update"
@@ -1,10 +0,0 @@
---
- name: Include gitea_runner download
ansible.builtin.include_tasks: download_gitea_runner.yml
- name: Restart gitea-runner systemd instance
ansible.builtin.systemd:
name: "gitea-runner-{{ runner_mode }}@{{ runner_name }}"
state: restarted
daemon_reload: true
when: systemd_available is defined and systemd_available.stat is defined and systemd_available.stat.exists
@@ -27,34 +27,18 @@
--no-interactive --no-interactive
args: args:
chdir: "{{ gitea_runner_data_dir }}" chdir: "{{ gitea_runner_data_dir }}"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
when: when:
- runner_file_stat.stat.exists | default(false) | bool - runner_file_stat.stat.exists | default(false) | bool
- not skip_runner_registration - not skip_runner_registration
- runner_mode == 'binary'
register: deregister_output register: deregister_output
changed_when: deregister_output.rc == 0 changed_when: deregister_output.rc == 0
failed_when: false failed_when: false
- name: Deregister runner with Gitea via Docker
ansible.builtin.command: >
docker run --rm
--entrypoint /usr/local/bin/gitea-runner
-v {{ gitea_runner_data_dir }}:/data
-w /data
{{ gitea_runner_docker_image }}:{{ gitea_runner_version | regex_replace('^v', '') }}
delete
--token {{ registration_token }}
--name {{ runner_name }}
--instance {{ gitea_url }}
--no-interactive
when:
- runner_file_stat.stat.exists | default(false) | bool
- not skip_runner_registration
- runner_mode == 'docker'
register: deregister_docker_output
changed_when: deregister_docker_output.rc == 0
failed_when: false
- name: Remove runner registration file - name: Remove runner registration file
ansible.builtin.file: ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}/.runner" path: "{{ gitea_runner_data_dir }}/.runner"
-100
View File
@@ -1,100 +0,0 @@
---
- name: Check if Docker is already installed
ansible.builtin.command: docker --version
register: docker_check
changed_when: false
failed_when: false
- name: Install Docker (Debian/Ubuntu)
when:
- ansible_facts['os_family'] == 'Debian'
- docker_check.rc != 0
block:
- name: Remove old Docker apt repository files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/apt/sources.list.d/docker.list
- /etc/apt/keyrings/docker.gpg
- name: Install prerequisite packages
ansible.builtin.apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
state: present
update_cache: true
cache_valid_time: 0
- name: Add Docker GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg
dest: "{{ docker_gpg_key_path }}"
mode: "0644"
- name: Add Docker repository
ansible.builtin.deb822_repository:
name: docker
types: deb
uris: https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}
suites: "{{ ansible_facts['distribution_release'] }}"
components: stable
signed_by: "{{ docker_gpg_key_path }}"
state: present
- name: Install Docker packages
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
cache_valid_time: 0
- name: Install Docker (Arch Linux)
when:
- ansible_facts['os_family'] == 'Archlinux'
- docker_check.rc != 0
block:
- name: Install Docker packages
community.general.pacman:
name:
- docker
- docker-compose
state: present
update_cache: true
- name: Ensure Docker service is running (systemd)
ansible.builtin.systemd:
name: docker
state: started
enabled: true
when: systemd_available.stat.exists
- name: Start Docker daemon manually (no systemd)
ansible.builtin.shell: nohup dockerd > /var/log/dockerd.log 2>&1 &
when:
- not systemd_available.stat.exists
- docker_check.rc != 0
changed_when: true
- name: Wait for Docker daemon to be ready
ansible.builtin.command: docker version
register: docker_ready
until: docker_ready.rc == 0
retries: 10
delay: 2
changed_when: false
when: not systemd_available.stat.exists
- name: Add user to docker group
ansible.builtin.user:
name: "{{ ansible_user | default(ansible_facts['user_id']) }}"
groups: docker
append: true
when: ansible_user is defined or ansible_facts['user_id'] is defined
@@ -1,55 +0,0 @@
---
- name: Include Docker installation
ansible.builtin.include_tasks: docker.yml
- name: Ensure runner data directory exists
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}"
state: directory
mode: "0755"
- name: Create gitea_runner config file in data directory
ansible.builtin.template:
src: gitea-runner-config.yaml.j2
dest: "{{ gitea_runner_data_dir }}/config.yaml"
mode: "0644"
- name: Pull gitea_runner image
ansible.builtin.command: docker pull {{ gitea_runner_docker_image }}:{{ gitea_runner_version | regex_replace('^v', '') }}
changed_when: "'Downloaded newer image' in pull_result.stdout or 'Status: Downloaded' in pull_result.stdout"
register: pull_result
- name: Check if runner is already registered
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_registered
- name: Register runner with Gitea via Docker
ansible.builtin.command: >
docker run --rm
--entrypoint /usr/local/bin/gitea-runner
-v {{ gitea_runner_data_dir }}:/data
-w /data
{{ gitea_runner_docker_image }}:{{ gitea_runner_version | regex_replace('^v', '') }}
register
--token {{ registration_token }}
--name {{ runner_name }}
--instance {{ gitea_url }}
--labels {{ runner_labels }}
--no-interactive
when:
- not runner_registered.stat.exists
- not skip_runner_registration
register: register_output
changed_when: "'already exists' not in register_output.stdout | default('')"
timeout: 60
- name: Restart gitea-runner service after registration
ansible.builtin.systemd:
name: "gitea-runner-docker@{{ runner_name }}"
state: restarted
daemon_reload: true
when: systemd_available.stat.exists | default(false) | bool
- name: Include service setup
ansible.builtin.include_tasks: service.yml
@@ -1,12 +0,0 @@
---
- name: Pull gitea_runner image
ansible.builtin.command: docker pull {{ gitea_runner_docker_image }}:{{ gitea_runner_version | regex_replace('^v', '') }}
changed_when: "'Downloaded newer image' in pull_result.stdout or 'Status: Downloaded' in pull_result.stdout"
register: pull_result
- name: Restart gitea-runner systemd instance
ansible.builtin.systemd:
name: "gitea-runner-{{ runner_mode }}@{{ runner_name }}"
state: restarted
daemon_reload: true
when: systemd_available is defined and systemd_available.stat is defined and systemd_available.stat.exists
@@ -1,18 +1,14 @@
--- ---
- name: Include Docker installation
ansible.builtin.include_tasks: docker.yml
- name: Include gitea_runner download - name: Include gitea_runner download
ansible.builtin.include_tasks: download_gitea_runner.yml ansible.builtin.include_tasks: download_gitea_runner.yml
- name: Ensure runner data directory exists - name: Create gitea_runner config file
ansible.builtin.file: ansible.builtin.template:
path: "{{ gitea_runner_data_dir }}" src: gitea-runner-config.yaml.j2
state: directory dest: "{{ gitea_runner_config_dir }}/config.yaml"
mode: "0755" owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
- name: Include config creation mode: "0644"
ansible.builtin.include_tasks: config.yml
- name: Include validation - name: Include validation
ansible.builtin.include_tasks: validate.yml ansible.builtin.include_tasks: validate.yml
@@ -18,19 +18,14 @@
else {} }} else {} }}
when: runner_file_stat.stat.exists | default(false) | bool when: runner_file_stat.stat.exists | default(false) | bool
- name: Verify runner container running (Docker mode) - name: Verify runner user service active
ansible.builtin.shell: | ansible.builtin.command: systemctl --user is-active gitea-runner
docker ps --filter "name=gitea-runner-{{ runner_name }}" --format "{{ '{{.Status}}' }}" become: true
register: docker_check become_user: "{{ gitea_runner_service_user }}"
changed_when: false environment:
when: runner_mode == 'docker' XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
- name: Verify runner service active (Binary mode)
ansible.builtin.systemd:
name: "gitea-runner-{{ runner_mode }}@{{ runner_name }}"
state: started
register: service_check register: service_check
when: runner_mode == 'binary' changed_when: false
- name: Validate runner installation - name: Validate runner installation
ansible.builtin.fail: ansible.builtin.fail:
@@ -39,16 +34,12 @@
{% if not (runner_file_stat.stat.exists | default(false)) %} {% if not (runner_file_stat.stat.exists | default(false)) %}
- Registration file (.runner) is missing. Registration may have failed. - Registration file (.runner) is missing. Registration may have failed.
{% endif %} {% endif %}
{% if runner_mode == 'docker' and not (docker_check.stdout | default('')) %} {% if not (service_check.stdout | default('') | trim) == 'active' %}
- Docker container is not running. - Systemd user service is not active.
{% endif %}
{% if runner_mode == 'binary' and not (service_check.status.ActiveState | default('')) == 'active' %}
- Systemd service is not active.
{% endif %} {% endif %}
when: > when: >
not (runner_file_stat.stat.exists | default(false)) not (runner_file_stat.stat.exists | default(false))
or (runner_mode == 'docker' and not (docker_check.stdout | default(''))) or not (service_check.stdout | default('') | trim) == 'active'
or (runner_mode == 'binary' and not (service_check.status.ActiveState | default('')) == 'active')
- name: Report runner status - name: Report runner status
ansible.builtin.debug: ansible.builtin.debug:
@@ -58,8 +49,7 @@
{% if runner_reg.id is defined %}Runner ID: {{ runner_reg.id }}{% endif %} {% 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.uuid is defined %}UUID: {{ runner_reg.uuid }}{% endif %}
{% if runner_reg.address is defined %}Gitea: {{ runner_reg.address }}{% endif %} {% if runner_reg.address is defined %}Gitea: {{ runner_reg.address }}{% endif %}
{% if runner_mode == 'docker' %}Container: {{ docker_check.stdout | default('unknown') }}{% endif %} Service: {{ service_check.stdout | default('unknown') | trim }}
{% if runner_mode == 'binary' %}Service: {{ service_check.status.ActiveState | default('unknown') }}{% endif %}
- name: Optional Gitea API verification - name: Optional Gitea API verification
when: when:
+7 -6
View File
@@ -2,13 +2,14 @@
- name: Include systemd availability check - name: Include systemd availability check
ansible.builtin.include_tasks: systemd_check.yml ansible.builtin.include_tasks: systemd_check.yml
- name: Install runner (Docker mode) - name: Include user setup
ansible.builtin.include_tasks: docker_mode.yml ansible.builtin.include_tasks: user_setup.yml
when: runner_mode == 'docker'
- name: Install runner (Binary mode) - name: Include rootless Docker setup
ansible.builtin.include_tasks: binary_mode.yml ansible.builtin.include_tasks: rootless_docker.yml
when: runner_mode == 'binary'
- name: Include runner install
ansible.builtin.include_tasks: install_runner.yml
- name: Include prune setup - name: Include prune setup
ansible.builtin.include_tasks: prune.yml ansible.builtin.include_tasks: prune.yml
+23 -10
View File
@@ -1,20 +1,33 @@
--- ---
- name: Create docker-prune service file - name: Create docker-prune user service file
ansible.builtin.template: ansible.builtin.template:
src: docker-prune.service.j2 src: docker-prune.service.j2
dest: /etc/systemd/system/docker-prune.service dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644" mode: "0644"
- name: Create docker-prune timer file - name: Create docker-prune user timer file
ansible.builtin.template: ansible.builtin.template:
src: docker-prune.timer.j2 src: docker-prune.timer.j2
dest: /etc/systemd/system/docker-prune.timer dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644" mode: "0644"
- name: Enable and start docker-prune timer - name: Reload systemd user daemon for prune timer
ansible.builtin.systemd: ansible.builtin.command: systemctl --user daemon-reload
name: docker-prune.timer become: true
state: started become_user: "{{ gitea_runner_service_user }}"
enabled: true environment:
daemon_reload: true XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
- name: Enable and start docker-prune user timer
ansible.builtin.command: systemctl --user enable --now docker-prune.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 when: systemd_available.stat.exists
@@ -3,6 +3,8 @@
ansible.builtin.file: ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}" path: "{{ gitea_runner_data_dir }}"
state: directory state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755" mode: "0755"
- name: Check if runner is already registered - name: Check if runner is already registered
@@ -12,7 +14,7 @@
- name: Register runner with Gitea - name: Register runner with Gitea
ansible.builtin.command: > ansible.builtin.command: >
"{{ gitea_runner_binary_path }}" register {{ gitea_runner_binary_path }} register
--token {{ registration_token }} --token {{ registration_token }}
--name {{ runner_name }} --name {{ runner_name }}
--instance {{ gitea_url }} --instance {{ gitea_url }}
@@ -20,14 +22,12 @@
--no-interactive --no-interactive
args: args:
chdir: "{{ gitea_runner_data_dir }}" chdir: "{{ gitea_runner_data_dir }}"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
when: not runner_registered.stat.exists when: not runner_registered.stat.exists
register: register_output register: register_output
changed_when: "'already exists' not in register_output.stdout | default('')" changed_when: "'already exists' not in register_output.stdout | default('')"
timeout: 60 timeout: 60
- name: Restart gitea-runner service after registration
ansible.builtin.systemd:
name: "gitea-runner-binary@{{ runner_name }}"
state: restarted
daemon_reload: true
when: systemd_available.stat.exists | default(false) | bool
@@ -0,0 +1,73 @@
---
- name: Install rootless Docker dependencies (Debian/Ubuntu)
ansible.builtin.apt:
name:
- uidmap
- slirp4netns
- fuse-overlayfs
- docker-ce
- docker-ce-cli
- docker-ce-rootless-extras
- containerd.io
- docker-compose-plugin
- rsync
state: present
update_cache: true
cache_valid_time: 0
when: ansible_facts['os_family'] == 'Debian'
- name: Install rootless Docker dependencies (Arch Linux)
community.general.pacman:
name:
- docker
- docker-compose
- slirp4netns
- fuse-overlayfs
- rsync
state: present
update_cache: true
when: ansible_facts['os_family'] == 'Archlinux'
- 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
- name: Set up rootless Docker for runner user
ansible.builtin.command: dockerd-rootless-setuptool.sh install
args:
creates: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: not rootless_docker_check.stat.exists
- name: Start rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user start docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
- name: Enable rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user enable docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
- name: Wait for rootless Docker daemon to be ready
ansible.builtin.command: docker version
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_ready
until: docker_ready.rc == 0
retries: 10
delay: 2
changed_when: false
+20 -12
View File
@@ -1,17 +1,25 @@
--- ---
- name: Create systemd template service file - name: Create systemd user service file
ansible.builtin.template: ansible.builtin.template:
src: gitea-runner@.service.j2 src: gitea-runner-user.service.j2
dest: "/etc/systemd/system/gitea-runner-{{ runner_mode }}@.service" dest: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644" mode: "0644"
notify:
- Reload systemd
- Restart gitea-runner
- name: Enable and start gitea-runner instance - name: Reload systemd user daemon
ansible.builtin.systemd: ansible.builtin.command: systemctl --user daemon-reload
name: "gitea-runner-{{ runner_mode }}@{{ runner_name }}" become: true
state: started become_user: "{{ gitea_runner_service_user }}"
enabled: true environment:
daemon_reload: true XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
- name: Enable and start gitea-runner user service
ansible.builtin.command: systemctl --user enable --now gitea-runner
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 when: systemd_available.stat.exists
@@ -0,0 +1,12 @@
---
- name: Include gitea_runner download
ansible.builtin.include_tasks: download_gitea_runner.yml
- name: Restart gitea-runner user service
ansible.builtin.command: systemctl --user restart gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists | default(false) | bool
changed_when: true
@@ -0,0 +1,65 @@
---
- name: Create per-runner system user
ansible.builtin.user:
name: "{{ gitea_runner_service_user }}"
home: "{{ gitea_runner_home }}"
shell: /bin/bash
system: true
create_home: true
register: runner_user
- name: Set runner UID fact
ansible.builtin.set_fact:
gitea_runner_uid: "{{ runner_user.uid }}"
- name: Enable lingering for runner user
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
changed_when: true
- name: Ensure subuid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subuid
regexp: "^{{ gitea_runner_service_user }}:"
line: "{{ gitea_runner_service_user }}:100000:65536"
create: true
mode: "0644"
- name: Ensure subgid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subgid
regexp: "^{{ gitea_runner_service_user }}:"
line: "{{ gitea_runner_service_user }}:100000:65536"
create: true
mode: "0644"
- name: Ensure XDG_RUNTIME_DIR exists
ansible.builtin.file:
path: "/run/user/{{ gitea_runner_uid }}"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0700"
- name: Ensure runner data directory exists
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
- name: Ensure runner config directory exists
ansible.builtin.file:
path: "{{ gitea_runner_config_dir }}"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
- name: Ensure systemd user directory exists
ansible.builtin.file:
path: "{{ gitea_runner_home }}/.config/systemd/user"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
@@ -14,7 +14,12 @@
register: gitea_runner_version_output register: gitea_runner_version_output
changed_when: false changed_when: false
- name: Verify Docker connectivity - name: Verify rootless Docker connectivity
ansible.builtin.command: docker version ansible.builtin.command: docker version
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_version_output register: docker_version_output
changed_when: false changed_when: false
@@ -1,9 +1,9 @@
[Unit] [Unit]
Description=Docker prune for Gitea runner resources Description=Docker prune for Gitea runner resources
After=docker.service
Requires=docker.service
[Service] [Service]
Type=oneshot 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 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 }}" --filter "until={{ gitea_runner_prune_until }}"
@@ -8,3 +8,4 @@ runner:
container: container:
label: "{{ gitea_runner_container_label }}" label: "{{ gitea_runner_container_label }}"
docker_host: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
@@ -0,0 +1,17 @@
[Unit]
Description=Gitea Actions Runner (rootless)
After=docker.service
[Service]
Type=simple
ExecStart={{ gitea_runner_binary_path }} daemon --config {{ gitea_runner_config_dir }}/config.yaml
WorkingDirectory={{ gitea_runner_data_dir }}
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
RestartSec={{ gitea_runner_service_restart_sec }}
[Install]
WantedBy=default.target
@@ -1,35 +0,0 @@
[Unit]
Description=Gitea Actions Runner %i ({% if runner_mode == 'docker' %}docker{% else %}binary{% endif %})
After=network.target{% if runner_mode == 'docker' %} docker.service
Requires=docker.service{% endif %}
[Service]
{% if runner_mode == 'docker' %}
Type=oneshot
RemainAfterExit=yes
ExecStartPre=-/usr/bin/docker rm -f gitea-runner-%i
ExecStart=/usr/bin/docker run -d --name gitea-runner-%i \
--restart=no \
-v /var/run/docker.sock:/var/run/docker.sock \
-v {{ gitea_runner_base_data_dir }}/%i:/data \
-w /data \
{{ gitea_runner_docker_image }}:{{ gitea_runner_version | regex_replace('^v', '') }} \
daemon --config /data/config.yaml
ExecStop=/usr/bin/docker stop -t 30 gitea-runner-%i
ExecStopPost=-/usr/bin/docker rm -f gitea-runner-%i
Restart=on-failure
RestartSec={{ gitea_runner_service_restart_sec }}
{% else %}
Type=simple
ExecStart={{ gitea_runner_binary_path }} daemon --config {{ gitea_runner_base_config_dir }}/%i/config.yaml
WorkingDirectory={{ gitea_runner_base_data_dir }}/%i
ExecStop=/bin/kill -TERM $MAINPID
TimeoutStopSec=30
Restart=on-failure
RestartSec={{ gitea_runner_service_restart_sec }}
User={{ gitea_runner_service_user }}
Group=docker
{% endif %}
[Install]
WantedBy=multi-user.target
+7 -5
View File
@@ -22,9 +22,11 @@
- not runner_registered.stat.exists - not runner_registered.stat.exists
- not skip_runner_registration | default(false) - not skip_runner_registration | default(false)
- name: Start gitea-runner systemd instance - name: Start gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: systemctl --user start gitea-runner
name: "gitea-runner@{{ runner_name }}" become: true
state: started become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
daemon_reload: true environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
changed_when: true
+9 -21
View File
@@ -9,19 +9,19 @@
name: gitea-runner name: gitea-runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Check systemd status - name: Check systemd user service status
ansible.builtin.systemd: ansible.builtin.command: systemctl --user is-active gitea-runner
name: "gitea-runner@{{ runner_name }}" become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
register: service_status register: service_status
changed_when: false
when: systemd_available.stat.exists when: systemd_available.stat.exists
- name: Report systemd status - name: Report service status
ansible.builtin.debug: ansible.builtin.debug:
msg: > msg: "Service gitea-runner: {{ service_status.stdout | default('unknown') | trim }}"
Service gitea-runner@{{ runner_name }}:
ActiveState={{ service_status.status.ActiveState | default('unknown') }},
SubState={{ service_status.status.SubState | default('unknown') }},
LoadState={{ service_status.status.LoadState | default('unknown') }}
when: systemd_available.stat.exists when: systemd_available.stat.exists
- name: Check runner registration file - name: Check runner registration file
@@ -32,15 +32,3 @@
- name: Report runner registration - name: Report runner registration
ansible.builtin.debug: ansible.builtin.debug:
msg: "Runner registration file exists: {{ runner_file_stat.stat.exists | default(false) }}" msg: "Runner registration file exists: {{ runner_file_stat.stat.exists | default(false) }}"
- name: Check container status (Docker mode)
ansible.builtin.shell: |
docker ps --filter "name=gitea-runner-{{ runner_name }}" --format "{{ '{{.Status}}' }}"
register: docker_check
changed_when: false
when: runner_mode | default('docker') == 'docker'
- name: Report container status
ansible.builtin.debug:
msg: "Container status: {{ docker_check.stdout | default('not running') }}"
when: runner_mode | default('docker') == 'docker'
+7 -5
View File
@@ -9,9 +9,11 @@
name: gitea-runner name: gitea-runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Stop gitea-runner systemd instance - name: Stop gitea-runner user service
ansible.builtin.systemd: ansible.builtin.command: systemctl --user stop gitea-runner
name: "gitea-runner@{{ runner_name }}" become: true
state: stopped become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
daemon_reload: true environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
changed_when: true
+2 -9
View File
@@ -4,14 +4,7 @@
become: true become: true
vars: {} vars: {}
tasks: tasks:
- name: Update runner (Docker mode) - name: Update runner
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea-runner
tasks_from: docker_update.yml tasks_from: update_runner.yml
when: runner_mode | default('docker') == 'docker'
- name: Update runner (Binary mode)
ansible.builtin.include_role:
name: gitea-runner
tasks_from: binary_update.yml
when: runner_mode | default('docker') == 'binary'
+77
View File
@@ -1,11 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Auto-merge PR by extracting task ID from branch and validating PR title. """Auto-merge PR by extracting task ID from branch and validating PR title.
Waits for CI checks to complete before attempting the merge.
Usage: Usage:
REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> [label_name] REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> [label_name]
""" """
import os import os
import time
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -16,6 +19,8 @@ from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _ from gitea_runner_manager.i18n import _
READY_TO_MERGE = "ready-to-merge" READY_TO_MERGE = "ready-to-merge"
MAX_WAIT_SECONDS = 900 # 15 minutes
POLL_INTERVAL_SECONDS = 30
load_dotenv(override=True) load_dotenv(override=True)
@@ -45,6 +50,66 @@ def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool:
return any(label.get("name") == READY_TO_MERGE for label in labels) return any(label.get("name") == READY_TO_MERGE for label in labels)
def wait_for_ci(
client: GiteaClient, sha: str, max_wait: int = MAX_WAIT_SECONDS, poll_interval: int = POLL_INTERVAL_SECONDS
) -> bool:
"""Poll commit statuses until all CI checks are complete (not pending).
Returns True if all checks are successful, False if any failed or timed out.
"""
elapsed = 0
while elapsed < max_wait:
statuses = client.get_commit_status(sha)
if not statuses:
click.echo(_("No CI checks reported yet, waiting..."))
time.sleep(poll_interval)
elapsed += poll_interval
continue
# Deduplicate by context — keep the latest status per context.
latest: dict[str, dict[str, object]] = {}
for s in statuses:
ctx = s.get("context", "")
if ctx not in latest or s.get("updated_at", "") > latest[ctx].get("updated_at", ""):
latest[ctx] = s
ci_statuses = {ctx: s for ctx, s in latest.items() if ctx.startswith("CI /")}
if not ci_statuses:
click.echo(_("No CI checks found yet, waiting..."))
time.sleep(poll_interval)
elapsed += poll_interval
continue
pending = [ctx for ctx, s in ci_statuses.items() if s.get("status") in ("pending", "waiting")]
if not pending:
# All CI checks are complete — check if they all succeeded.
failed = [
ctx
for ctx, s in ci_statuses.items()
if s.get("status") not in ("success", "ok")
]
if failed:
click.echo(
_("CI checks failed: {failed}", failed=", ".join(sorted(failed)))
)
return False
click.echo(_("All CI checks passed."))
return True
click.echo(
_(
"Waiting for CI checks: {pending} ({elapsed}s elapsed)",
pending=", ".join(sorted(pending)),
elapsed=elapsed,
)
)
time.sleep(poll_interval)
elapsed += poll_interval
click.echo(_("Timed out waiting for CI checks after {max_wait}s.", max_wait=max_wait))
return False
@click.command() @click.command()
@click.argument("branch") @click.argument("branch")
@click.argument("pr_title") @click.argument("pr_title")
@@ -75,6 +140,18 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str)
validate_pr_title(pr_title) validate_pr_title(pr_title)
# Wait for CI checks to complete before attempting merge.
pr = client.get_pr(pr_number)
sha = pr.get("head", {}).get("sha", "")
if sha:
click.echo(_("Waiting for CI checks on commit {sha}...", sha=sha[:8]))
if not wait_for_ci(client, sha):
raise click.ClickException(
_("Cannot merge: CI checks did not pass. Please fix failing checks and re-label.")
)
else:
click.echo(_("Warning: could not determine PR head SHA, proceeding without CI wait."))
merge_title = f"{task_id}: {pr_title}" merge_title = f"{task_id}: {pr_title}"
try: try:
-2
View File
@@ -7,10 +7,8 @@ Usage:
from __future__ import annotations from __future__ import annotations
import argparse
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import sys
import click import click
+76 -21
View File
@@ -1,19 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Distribute molecule scenarios across N parallel runners. """Distribute molecule (scenario, platform) pairs across N parallel runners.
Discovers all molecule scenarios under ansible/roles/*/molecule/ and Discovers all molecule scenarios under ansible/roles/*/molecule/ and
splits them evenly across the requested number of runners. crosses them with the supported OS platform matrix, then splits the
resulting test pairs evenly across the requested number of runners.
Each pair is printed as ``scenario|platform_name|platform_image|platform_command``
so the CI workflow can set the appropriate environment variables.
Usage: Usage:
python3 scripts/distribute_molecule.py --runner-index 0 --max-runners 3 python3 scripts/distribute_molecule.py --runner-index 0 --max-runners 3
# prints: default deregister # prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
python3 scripts/distribute_molecule.py --list python3 scripts/distribute_molecule.py --list
# prints all scenarios, one per line # prints all scenarios, one per line
python3 scripts/distribute_molecule.py --list-platforms
# prints all platforms, one per line
""" """
from __future__ import annotations from __future__ import annotations
import sys from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import click import click
@@ -23,6 +29,36 @@ from gitea_runner_manager.i18n import _
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule") MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
#: Supported OS platform matrix.
#: Each entry maps a short name to (image, command).
PLATFORMS: list[dict[str, str]] = [
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": ""},
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": ""},
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": ""},
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
]
@dataclass(frozen=True)
class TestPair:
"""A (scenario, platform) combination to test."""
scenario: str
platform: dict[str, str]
def encode(self) -> str:
"""Serialize to a pipe-delimited string for CI consumption."""
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
@staticmethod
def decode(encoded: str) -> TestPair:
"""Deserialize from a pipe-delimited string."""
parts = encoded.split("|")
return TestPair(
scenario=parts[0],
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
)
def discover_scenarios(root: Path | None = None) -> list[str]: def discover_scenarios(root: Path | None = None) -> list[str]:
"""Return sorted list of molecule scenario directory names.""" """Return sorted list of molecule scenario directory names."""
@@ -40,19 +76,26 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
return sorted(scenarios) return sorted(scenarios)
def distribute(scenarios: list[str], max_runners: int) -> list[list[str]]: def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
"""Split *scenarios* into *max_runners* balanced groups (round-robin).""" """Build the full cross-product of scenarios and platforms."""
groups: list[list[str]] = [[] for _ in range(max_runners)] if platforms is None:
for i, scenario in enumerate(scenarios): platforms = PLATFORMS
groups[i % max_runners].append(scenario) return [TestPair(s, p) for s in scenarios for p in platforms]
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups return groups
def scenarios_for_runner( def pairs_for_runner(
scenarios: list[str], runner_index: int, max_runners: int pairs: list[TestPair], runner_index: int, max_runners: int
) -> list[str]: ) -> list[TestPair]:
"""Return the subset of scenarios assigned to *runner_index*.""" """Return the subset of pairs assigned to *runner_index*."""
groups = distribute(scenarios, max_runners) groups = distribute(pairs, max_runners)
if runner_index < 0 or runner_index >= len(groups): if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException( raise click.ClickException(
_( _(
@@ -84,19 +127,31 @@ def scenarios_for_runner(
is_flag=True, is_flag=True,
help="List all discovered scenarios, one per line.", help="List all discovered scenarios, one per line.",
) )
def cli(runner_index: int | None, max_runners: int, list_all: bool) -> None: @click.option(
"--list-platforms",
"list_platforms",
is_flag=True,
help="List all supported platforms, one per line.",
)
def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platforms: bool) -> None:
scenarios = discover_scenarios() scenarios = discover_scenarios()
if list_all: if list_all:
for s in scenarios: for s in scenarios:
click.echo(s) click.echo(s)
return return
if runner_index is None: if list_platforms:
groups = distribute(scenarios, max_runners) for p in PLATFORMS:
for i, group in enumerate(groups): click.echo(f"{p['name']}|{p['image']}|{p['command']}")
click.echo(f"Runner {i}: {' '.join(group) if group else '(none)'}")
return return
assigned = scenarios_for_runner(scenarios, runner_index, max_runners) pairs = build_pairs(scenarios)
click.echo(" ".join(assigned)) if runner_index is None:
groups = distribute(pairs, max_runners)
for i, group in enumerate(groups):
labels = " ".join(p.encode() for p in group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
assigned = pairs_for_runner(pairs, runner_index, max_runners)
click.echo(" ".join(p.encode() for p in assigned))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+1 -3
View File
@@ -10,10 +10,8 @@ from __future__ import annotations
import platform import platform
import shutil import shutil
import subprocess # nosec B404 import subprocess # nosec B404
import sys
from pathlib import Path
import urllib.request import urllib.request
from pathlib import Path
import click import click
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Run all molecule scenarios on all supported OS platforms.
# Used by `make molecule-all`. Sequential — CI uses parallel matrix instead.
set -euo pipefail
MOLECULE_BIN="$(realpath "${BIN:-.venv/bin}/molecule")"
ROLE_DIR="$(cd "$(dirname "$0")/.." && pwd)/ansible/roles/gitea-runner"
for p in \
ubuntu-2204:geerlingguy/docker-ubuntu2204-ansible:latest: \
ubuntu-2404:geerlingguy/docker-ubuntu2404-ansible:latest: \
debian-12:geerlingguy/docker-debian12-ansible:latest: \
archlinux:marcstraube/archlinux-ansible:latest:/usr/lib/systemd/systemd
do
IFS=":" read -r name image command <<< "$p"
export MOLECULE_PLATFORM_NAME="$name" MOLECULE_PLATFORM_IMAGE="$image"
if [ -n "$command" ]; then
export MOLECULE_PLATFORM_COMMAND="$command"
else
unset MOLECULE_PLATFORM_COMMAND
fi
echo "=== Platform: $name ==="
for s in default multi-instance lifecycle template-content deregister update; do
echo "--- Scenario: $s on $name ---"
(
cd "$ROLE_DIR"
if [ "$s" = "default" ]; then
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test
else
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test -s "$s"
fi
)
done
done
+10
View File
@@ -116,6 +116,16 @@ class GiteaClient:
payload = {"Do": "squash", "MergeTitleField": merge_title} payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload) self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
"""Fetch all status check contexts reported for a commit."""
r = self._request("GET", f"/commits/{sha}/statuses")
return r.json()
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
"""Fetch pull request details including mergeable state."""
r = self._request("GET", f"/pulls/{pr_number}")
return r.json()
def create_release( def create_release(
self, self,
tag: str, tag: str,
+3 -53
View File
@@ -69,13 +69,6 @@ def cli() -> None:
default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"), default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"),
help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"), help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"),
) )
@click.option(
"--mode",
"-m",
type=click.Choice(["docker", "binary"]),
default="docker",
help=_("Gitea Runner deployment mode (default: docker)"),
)
@click.option( @click.option(
"--url", "--url",
default=lambda: os.getenv("GITEA_URL", ""), default=lambda: os.getenv("GITEA_URL", ""),
@@ -111,7 +104,6 @@ def install(
key: str | None, key: str | None,
name: str | None, name: str | None,
token: str | None, token: str | None,
mode: str,
url: str, url: str,
admin_token: str | None, admin_token: str | None,
integration_retries: int, integration_retries: int,
@@ -127,7 +119,6 @@ def install(
name=name, name=name,
token=token, token=token,
gitea_url=url, gitea_url=url,
mode=mode,
admin_token=admin_token, admin_token=admin_token,
integration_retries=integration_retries, integration_retries=integration_retries,
labels=labels or None, labels=labels or None,
@@ -147,13 +138,6 @@ def install(
) )
@click.option("--key", "-k", default=lambda: os.getenv("GITEA_RUNNER_KEY"), help=_("Path to SSH private key")) @click.option("--key", "-k", default=lambda: os.getenv("GITEA_RUNNER_KEY"), help=_("Path to SSH private key"))
@click.option("--version", "-v", help=_("Specific Gitea Runner version")) @click.option("--version", "-v", help=_("Specific Gitea Runner version"))
@click.option(
"--mode",
"-m",
type=click.Choice(["docker", "binary"]),
default="docker",
help=_("Gitea Runner deployment mode (default: docker)"),
)
@click.option( @click.option(
"--ask-become-pass/--no-ask-become-pass", "--ask-become-pass/--no-ask-become-pass",
default=True, default=True,
@@ -165,7 +149,6 @@ def update(
user: str, user: str,
key: str | None, key: str | None,
version: str | None, version: str | None,
mode: str,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
manager = RunnerManager() manager = RunnerManager()
@@ -174,7 +157,6 @@ def update(
user=user, user=user,
key=key, key=key,
version=version, version=version,
mode=mode,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
) )
@@ -182,19 +164,12 @@ def update(
@cli.command(help=_("Start a registered Gitea Runner.")) @cli.command(help=_("Start a registered Gitea Runner."))
@click.argument("runner_name") @click.argument("runner_name")
@_runner_options @_runner_options
@click.option(
"--mode",
"-m",
type=click.Choice(["docker", "binary"]),
help=_("Override deployment mode from registry"),
)
@_handle_errors("Start failed: {error}") @_handle_errors("Start failed: {error}")
def start( def start(
runner_name: str, runner_name: str,
host: str | None, host: str | None,
user: str | None, user: str | None,
key: str | None, key: str | None,
mode: str | None,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
manager = RunnerManager() manager = RunnerManager()
@@ -203,7 +178,6 @@ def start(
host=host, host=host,
user=user, user=user,
key=key, key=key,
mode=mode,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
) )
@@ -259,12 +233,6 @@ def enable(
default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"), default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"),
help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"), help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"),
) )
@click.option(
"--mode",
"-m",
type=click.Choice(["docker", "binary"]),
help=_("Override deployment mode from registry"),
)
@click.option( @click.option(
"--url", "--url",
default=lambda: os.getenv("GITEA_URL", ""), default=lambda: os.getenv("GITEA_URL", ""),
@@ -277,7 +245,6 @@ def disable(
user: str | None, user: str | None,
key: str | None, key: str | None,
token: str | None, token: str | None,
mode: str | None,
url: str, url: str,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
@@ -289,7 +256,6 @@ def disable(
key=key, key=key,
token=token, token=token,
gitea_url=url, gitea_url=url,
mode=mode,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
) )
@@ -297,19 +263,12 @@ def disable(
@cli.command(help=_("Check the status of a registered Gitea Runner.")) @cli.command(help=_("Check the status of a registered Gitea Runner."))
@click.argument("runner_name") @click.argument("runner_name")
@_runner_options @_runner_options
@click.option(
"--mode",
"-m",
type=click.Choice(["docker", "binary"]),
help=_("Override deployment mode from registry"),
)
@_handle_errors("Status check failed: {error}") @_handle_errors("Status check failed: {error}")
def status( def status(
runner_name: str, runner_name: str,
host: str | None, host: str | None,
user: str | None, user: str | None,
key: str | None, key: str | None,
mode: str | None,
ask_become_pass: bool, ask_become_pass: bool,
) -> None: ) -> None:
manager = RunnerManager() manager = RunnerManager()
@@ -318,7 +277,6 @@ def status(
host=host, host=host,
user=user, user=user,
key=key, key=key,
mode=mode,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
) )
@@ -332,12 +290,6 @@ def status(
default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"), default=lambda: os.getenv("GITEA_REGISTRATION_TOKEN"),
help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"), help=_("Registration token (env: GITEA_REGISTRATION_TOKEN)"),
) )
@click.option(
"--mode",
"-m",
type=click.Choice(["docker", "binary"]),
help=_("Override deployment mode from registry"),
)
@click.option( @click.option(
"--url", "--url",
default=lambda: os.getenv("GITEA_URL", ""), default=lambda: os.getenv("GITEA_URL", ""),
@@ -356,7 +308,6 @@ def remove(
user: str | None, user: str | None,
key: str | None, key: str | None,
token: str | None, token: str | None,
mode: str | None,
url: str, url: str,
force: bool, force: bool,
ask_become_pass: bool, ask_become_pass: bool,
@@ -369,7 +320,6 @@ def remove(
key=key, key=key,
token=token, token=token,
gitea_url=url, gitea_url=url,
mode=mode,
ask_become_pass=ask_become_pass, ask_become_pass=ask_become_pass,
force=force, force=force,
) )
@@ -385,7 +335,7 @@ def list_runners() -> None:
click.echo(_("No runners registered. Use 'grm install' to add one.")) click.echo(_("No runners registered. Use 'grm install' to add one."))
return return
click.echo(f"{_('NAME'):<18} {_('HOST'):<16} {_('USER'):<10} {_('MODE'):<8} {_('LABELS'):<30} {_('STATUS')}") click.echo(f"{_('NAME'):<18} {_('HOST'):<16} {_('USER'):<10} {_('LABELS'):<30} {_('STATUS')}")
click.echo("-" * 100) click.echo("-" * 90)
for r in runners: for r in runners:
click.echo(f"{r['name']:<18} {r['host']:<16} {r['user']:<10} {r['mode']:<8} {r['labels']:<30} {r['status']}") click.echo(f"{r['name']:<18} {r['host']:<16} {r['user']:<10} {r['labels']:<30} {r['status']}")
+1 -1
View File
@@ -24,7 +24,7 @@ BRANCH_PROTECTION_CONFIG: dict[str, object] = {
"branch_name": "master", "branch_name": "master",
"enable_push": False, "enable_push": False,
"enable_status_check": True, "enable_status_check": True,
"status_check_contexts": ["lint", "unit-tests", "molecule-tests"], "status_check_contexts": ["CI / quality", "CI / molecule-tests*"],
"required_approvals": 1, "required_approvals": 1,
"dismiss_stale_approvals": True, "dismiss_stale_approvals": True,
"block_on_outdated_branch": True, "block_on_outdated_branch": True,
+1 -3
View File
@@ -1,6 +1,6 @@
"""Local JSON registry for installed Gitea Runners. """Local JSON registry for installed Gitea Runners.
Stores connection metadata (host, user, key, mode) on the local machine Stores connection metadata (host, user, key, labels) on the local machine
so that subsequent lifecycle commands only need the runner name. so that subsequent lifecycle commands only need the runner name.
""" """
@@ -44,7 +44,6 @@ class RunnerRegistry:
host: str, host: str,
user: str, user: str,
key: str | None = None, key: str | None = None,
mode: str = "docker",
gitea_url: str = "", gitea_url: str = "",
labels: str | None = None, labels: str | None = None,
) -> None: ) -> None:
@@ -53,7 +52,6 @@ class RunnerRegistry:
"host": host, "host": host,
"user": user, "user": user,
"key": key, "key": key,
"mode": mode,
"gitea_url": gitea_url, "gitea_url": gitea_url,
"labels": labels, "labels": labels,
"created_at": datetime.now(UTC).isoformat(), "created_at": datetime.now(UTC).isoformat(),
+28 -99
View File
@@ -31,7 +31,6 @@ class RunnerManager:
name: str | None = None, name: str | None = None,
token: str | None = None, token: str | None = None,
gitea_url: str = "", gitea_url: str = "",
mode: str = "docker",
admin_token: str | None = None, admin_token: str | None = None,
integration_retries: int = 3, integration_retries: int = 3,
ask_become_pass: bool = False, ask_become_pass: bool = False,
@@ -47,7 +46,6 @@ class RunnerManager:
extra_vars = ( extra_vars = (
f"registration_token={token} runner_name={name} gitea_url={gitea_url}" f"registration_token={token} runner_name={name} gitea_url={gitea_url}"
f" runner_mode={mode}"
f" gitea_runner_integration_retries={integration_retries}" f" gitea_runner_integration_retries={integration_retries}"
) )
if admin_token: if admin_token:
@@ -67,7 +65,6 @@ class RunnerManager:
host=host, host=host,
user=user, user=user,
key=key, key=key,
mode=mode,
gitea_url=gitea_url, gitea_url=gitea_url,
labels=labels, labels=labels,
) )
@@ -79,11 +76,10 @@ class RunnerManager:
user: str, user: str,
key: str | None = None, key: str | None = None,
version: str | None = None, version: str | None = None,
mode: str = "docker",
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Update the gitea_runner binary on a remote host.""" """Update the gitea_runner binary on a remote host."""
extra_vars = f"runner_mode={mode}" extra_vars = ""
if version: if version:
extra_vars += f" gitea_runner_version={version}" extra_vars += f" gitea_runner_version={version}"
@@ -99,11 +95,10 @@ class RunnerManager:
host: str | None = None, host: str | None = None,
user: str | None = None, user: str | None = None,
key: str | None = None, key: str | None = None,
mode: str | None = None, ) -> tuple[str, str, str | None, str]:
) -> tuple[str, str, str | None, str, str]:
"""Look up runner metadata from registry, applying CLI overrides. """Look up runner metadata from registry, applying CLI overrides.
Returns ``(host, user, key, mode, gitea_url)`` where *gitea_url* is Returns ``(host, user, key, gitea_url)`` where *gitea_url* is
taken from the registry when available, allowing ``disable`` and taken from the registry when available, allowing ``disable`` and
``remove`` to reuse the value stored at install time. ``remove`` to reuse the value stored at install time.
""" """
@@ -114,12 +109,10 @@ class RunnerManager:
actual_host = host actual_host = host
actual_user = user actual_user = user
actual_key = key actual_key = key
actual_mode = mode or "docker"
elif info: elif info:
actual_host = host or info["host"] actual_host = host or info["host"]
actual_user = user or info["user"] actual_user = user or info["user"]
actual_key = key if key is not None else info.get("key") actual_key = key if key is not None else info.get("key")
actual_mode = mode or info.get("mode", "docker")
else: else:
raise AnsibleError( raise AnsibleError(
_( _(
@@ -127,7 +120,7 @@ class RunnerManager:
name=name, name=name,
) )
) )
return actual_host, actual_user, actual_key, actual_mode, actual_gitea_url return actual_host, actual_user, actual_key, actual_gitea_url
def start( def start(
self, self,
@@ -135,16 +128,13 @@ class RunnerManager:
host: str | None = None, host: str | None = None,
user: str | None = None, user: str | None = None,
key: str | None = None, key: str | None = None,
mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Start a runner instance on a remote host.""" """Start a runner instance on a remote host."""
actual_host, actual_user, actual_key, actual_mode, _gitea_url = self._resolve_runner( actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
name, host, user, key, mode
)
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin(_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host)) tracker.begin(_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host))
extra_vars = f"runner_name={name} runner_mode={actual_mode}" extra_vars = f"runner_name={name}"
cmd = self._build_cmd("start-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass) cmd = self._build_cmd("start-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass)
self._executor.run( self._executor.run(
cmd, description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host) cmd, description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host)
@@ -160,7 +150,7 @@ class RunnerManager:
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Stop a runner instance on a remote host.""" """Stop a runner instance on a remote host."""
actual_host, actual_user, actual_key, _mode, _gitea_url = self._resolve_runner(name, host, user, key) actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin(_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host)) tracker.begin(_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host))
extra_vars = f"runner_name={name}" extra_vars = f"runner_name={name}"
@@ -179,7 +169,7 @@ class RunnerManager:
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Enable a runner instance to start on boot.""" """Enable a runner instance to start on boot."""
actual_host, actual_user, actual_key, _mode, _gitea_url = self._resolve_runner(name, host, user, key) actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin(_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host)) tracker.begin(_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host))
extra_vars = f"runner_name={name}" extra_vars = f"runner_name={name}"
@@ -199,13 +189,10 @@ class RunnerManager:
key: str | None = None, key: str | None = None,
token: str | None = None, token: str | None = None,
gitea_url: str = "", gitea_url: str = "",
mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Disable and deregister a runner instance.""" """Disable and deregister a runner instance."""
actual_host, actual_user, actual_key, actual_mode, registry_gitea_url = self._resolve_runner( actual_host, actual_user, actual_key, registry_gitea_url = self._resolve_runner(name, host, user, key)
name, host, user, key, mode
)
resolved_gitea_url = gitea_url or registry_gitea_url resolved_gitea_url = gitea_url or registry_gitea_url
if not resolved_gitea_url: if not resolved_gitea_url:
raise AnsibleError(_("GITEA_URL must be set (or pass --url)")) raise AnsibleError(_("GITEA_URL must be set (or pass --url)"))
@@ -213,10 +200,7 @@ class RunnerManager:
raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)")) raise AnsibleError(_("GITEA_REGISTRATION_TOKEN must be set (or pass --token)"))
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin(_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host)) tracker.begin(_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host))
extra_vars = ( extra_vars = f"runner_name={name} registration_token={token} gitea_url={resolved_gitea_url}"
f"runner_name={name} registration_token={token}"
f" gitea_url={resolved_gitea_url} runner_mode={actual_mode}"
)
cmd = self._build_cmd( cmd = self._build_cmd(
"disable-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass "disable-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass
) )
@@ -231,16 +215,13 @@ class RunnerManager:
host: str | None = None, host: str | None = None,
user: str | None = None, user: str | None = None,
key: str | None = None, key: str | None = None,
mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
) -> None: ) -> None:
"""Check the status of a runner instance.""" """Check the status of a runner instance."""
actual_host, actual_user, actual_key, actual_mode, _gitea_url = self._resolve_runner( actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
name, host, user, key, mode
)
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin(_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host)) tracker.begin(_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host))
extra_vars = f"runner_name={name} runner_mode={actual_mode}" extra_vars = f"runner_name={name}"
cmd = self._build_cmd( cmd = self._build_cmd(
"status-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass "status-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass
) )
@@ -257,7 +238,6 @@ class RunnerManager:
key: str | None = None, key: str | None = None,
token: str | None = None, token: str | None = None,
gitea_url: str = "", gitea_url: str = "",
mode: str | None = None,
ask_become_pass: bool = False, ask_become_pass: bool = False,
force: bool = False, force: bool = False,
) -> None: ) -> None:
@@ -267,9 +247,7 @@ class RunnerManager:
only remove the local registry entry. Use this when the remote only remove the local registry entry. Use this when the remote
host is already gone or unreachable. host is already gone or unreachable.
""" """
actual_host, actual_user, actual_key, actual_mode, registry_gitea_url = self._resolve_runner( actual_host, actual_user, actual_key, registry_gitea_url = self._resolve_runner(name, host, user, key)
name, host, user, key, mode
)
resolved_gitea_url = gitea_url or registry_gitea_url resolved_gitea_url = gitea_url or registry_gitea_url
resolved_token = token resolved_token = token
if not force: if not force:
@@ -280,10 +258,7 @@ class RunnerManager:
with track_steps() as tracker: with track_steps() as tracker:
tracker.begin(_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host)) tracker.begin(_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host))
if not force: if not force:
extra_vars = ( extra_vars = f"runner_name={name} registration_token={resolved_token} gitea_url={resolved_gitea_url}"
f"runner_name={name} registration_token={resolved_token}"
f" gitea_url={resolved_gitea_url} runner_mode={actual_mode}"
)
cmd = self._build_cmd( cmd = self._build_cmd(
"remove-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass "remove-runner.yml", actual_host, actual_user, extra_vars, actual_key, ask_become_pass
) )
@@ -304,7 +279,6 @@ class RunnerManager:
host = info["host"] host = info["host"]
user = info["user"] user = info["user"]
key = info.get("key") key = info.get("key")
mode = info.get("mode", "docker")
say( say(
_( _(
"Checking status of Gitea Runner {name} on {host} as {user} (sudo required)", "Checking status of Gitea Runner {name} on {host} as {user} (sudo required)",
@@ -315,64 +289,20 @@ class RunnerManager:
) )
service_status = "unknown" service_status = "unknown"
if mode == "docker": try:
# Try expected container name first, then host-based fallback for legacy installs. stdout = self._executor.run_ad_hoc(
# Use python3 JSON parsing to avoid Jinja2 brace conflicts in Ansible shell args. host,
for container_name in (f"gitea-runner-{name}", f"gitea-runner-{host}"): user,
try: key,
stdout = self._executor.run_ad_hoc( "shell",
host, f"sudo -u grm-{name} systemctl --user is-active gitea-runner 2>/dev/null",
user, become=True,
key, ask_become_pass=True,
"shell", check=False,
( )
f"docker inspect {container_name} 2>/dev/null | " service_status = self._parse_status(stdout)
f"python3 -c \"import sys,json; print(json.load(sys.stdin)[0]['State']['Status'])\" " except AnsibleError:
f"2>/dev/null || echo unknown" service_status = "unknown"
),
become=True,
ask_become_pass=True,
check=False,
)
except AnsibleError:
continue
status = self._parse_status(stdout)
if status != "unknown":
docker_map = {"running": "active", "exited": "inactive", "dead": "failed"}
service_status = docker_map.get(status, "unknown")
break
else:
# Final fallback: check systemd service (for installs using the fixed template)
try:
stdout = self._executor.run_ad_hoc(
host,
user,
key,
"shell",
f"systemctl is-active gitea-runner@{name} 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
)
service_status = self._parse_status(stdout)
except AnsibleError:
service_status = "unknown"
else:
try:
stdout = self._executor.run_ad_hoc(
host,
user,
key,
"shell",
f"systemctl is-active gitea-runner@{name} 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
)
service_status = self._parse_status(stdout)
except AnsibleError:
service_status = "unknown"
# Translate known status values
translated_status = _( translated_status = _(
service_status if service_status in {"active", "inactive", "failed", "unknown"} else "unknown" service_status if service_status in {"active", "inactive", "failed", "unknown"} else "unknown"
) )
@@ -381,7 +311,6 @@ class RunnerManager:
"name": name, "name": name,
"host": host, "host": host,
"user": user, "user": user,
"mode": mode,
"labels": info.get("labels", ""), "labels": info.get("labels", ""),
"status": translated_status, "status": translated_status,
} }
+19 -10
View File
@@ -27,46 +27,55 @@ class TestLifecycleCLI:
mock_manager.install.assert_called_once() mock_manager.install.assert_called_once()
# Start # Start
result = runner.invoke(cli, ["start", "host1", "--user", "ubuntu", "--name", "r1"]) result = runner.invoke(cli, ["start", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.start.assert_called_once_with( mock_manager.start.assert_called_once_with(
host="host1", user="ubuntu", name="r1", mode="docker", ask_become_pass=False host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
) )
# Status # Status
result = runner.invoke(cli, ["status", "host1", "--user", "ubuntu", "--name", "r1"]) result = runner.invoke(cli, ["status", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.status.assert_called_once_with( mock_manager.status.assert_called_once_with(
host="host1", user="ubuntu", name="r1", mode="docker", ask_become_pass=False host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
) )
# Stop # Stop
result = runner.invoke(cli, ["stop", "host1", "--user", "ubuntu", "--name", "r1"]) result = runner.invoke(cli, ["stop", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.stop.assert_called_once_with(host="host1", user="ubuntu", name="r1", ask_become_pass=False) mock_manager.stop.assert_called_once_with(
host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
)
# Disable # Disable
result = runner.invoke(cli, ["disable", "host1", "--user", "ubuntu", "--name", "r1", "--token", "tok"]) result = runner.invoke(
cli,
["disable", "r1", "--host", "host1", "--user", "ubuntu", "--token", "tok", "--no-ask-become-pass"],
)
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.disable.assert_called_once_with( mock_manager.disable.assert_called_once_with(
host="host1", host="host1",
user="ubuntu", user="ubuntu",
name="r1", name="r1",
key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker",
ask_become_pass=False, ask_become_pass=False,
) )
# Remove # Remove
result = runner.invoke(cli, ["remove", "host1", "--user", "ubuntu", "--name", "r1", "--token", "tok"]) result = runner.invoke(
cli,
["remove", "r1", "--host", "host1", "--user", "ubuntu", "--token", "tok", "--no-ask-become-pass"],
)
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.remove.assert_called_once_with( mock_manager.remove.assert_called_once_with(
host="host1", host="host1",
user="ubuntu", user="ubuntu",
name="r1", name="r1",
key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker",
ask_become_pass=False, ask_become_pass=False,
force=False,
) )
+9 -7
View File
@@ -26,15 +26,13 @@ class TestMultiInstanceCLI:
assert result.exit_code == 0 assert result.exit_code == 0
# Install instance B # Install instance B
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--name", "runner-b", "--mode", "binary"]) result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--name", "runner-b"])
assert result.exit_code == 0 assert result.exit_code == 0
assert mock_manager.install.call_count == 2 assert mock_manager.install.call_count == 2
calls = mock_manager.install.call_args_list calls = mock_manager.install.call_args_list
assert calls[0].kwargs["name"] == "runner-a" assert calls[0].kwargs["name"] == "runner-a"
assert calls[0].kwargs["mode"] == "docker"
assert calls[1].kwargs["name"] == "runner-b" assert calls[1].kwargs["name"] == "runner-b"
assert calls[1].kwargs["mode"] == "binary"
@patch("gitea_runner_manager.cli.RunnerManager") @patch("gitea_runner_manager.cli.RunnerManager")
def test_start_stop_one_instance(self, mock_manager_class: MagicMock) -> None: def test_start_stop_one_instance(self, mock_manager_class: MagicMock) -> None:
@@ -44,12 +42,16 @@ class TestMultiInstanceCLI:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["start", "host1", "--user", "ubuntu", "--name", "runner-a"]) result = runner.invoke(
cli, ["start", "runner-a", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"]
)
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.start.assert_called_once_with( mock_manager.start.assert_called_once_with(
host="host1", user="ubuntu", name="runner-a", mode="docker", ask_become_pass=False host="host1", user="ubuntu", name="runner-a", key=None, ask_become_pass=False
) )
result = runner.invoke(cli, ["stop", "host1", "--user", "ubuntu", "--name", "runner-b"]) result = runner.invoke(cli, ["stop", "runner-b", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.stop.assert_called_once_with(host="host1", user="ubuntu", name="runner-b", ask_become_pass=False) mock_manager.stop.assert_called_once_with(
host="host1", user="ubuntu", name="runner-b", key=None, ask_become_pass=False
)
+27
View File
@@ -201,6 +201,33 @@ class TestGiteaClient:
timeout=DEFAULT_TIMEOUT, timeout=DEFAULT_TIMEOUT,
) )
def test_get_commit_status(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"context": "CI / quality", "status": "success"}])
)
result = client.get_commit_status("abc123")
assert result == [{"context": "CI / quality", "status": "success"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/commits/abc123/statuses",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}}))
result = client.get_pr(7)
assert result["number"] == 7
assert result["head"]["sha"] == "abc123"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7",
timeout=DEFAULT_TIMEOUT,
)
def test_update_repo_settings(self) -> None: def test_update_repo_settings(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True})) client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
+132 -1
View File
@@ -9,7 +9,21 @@ from click.testing import CliRunner
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.exceptions import APIError
from scripts.auto_merge import extract_task_id, has_ready_to_merge_label, main, validate_pr_title from scripts.auto_merge import (
extract_task_id,
has_ready_to_merge_label,
main,
validate_pr_title,
wait_for_ci,
)
CI_SUCCESS = "success"
CI_PENDING = "pending"
CI_FAILURE = "failure"
def _status(context: str, status: str, updated_at: str = "2026-01-01T00:00:00Z") -> dict[str, str]:
return {"context": context, "status": status, "updated_at": updated_at}
class TestRegexes: class TestRegexes:
@@ -70,12 +84,91 @@ class TestHasReadyToMergeLabel:
assert has_ready_to_merge_label(client, "5") is False assert has_ready_to_merge_label(client, "5") is False
class TestWaitForCi:
def test_all_pass_immediately(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_SUCCESS),
_status("CI / molecule-tests (0) (pull_request)", CI_SUCCESS),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_waits_then_passes(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[_status("CI / quality (pull_request)", CI_PENDING)],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def test_fails_on_failed_check(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_SUCCESS),
_status("CI / molecule-tests (0) (pull_request)", CI_FAILURE),
]
assert wait_for_ci(client, "abc123", max_wait=10) is False
def test_times_out(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_PENDING),
]
with patch("scripts.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=5) is False
def test_no_statuses_waits(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def test_ignores_non_ci_contexts(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("Auto-merge / merge (pull_request)", CI_PENDING),
_status("CI / quality (pull_request)", CI_SUCCESS),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_deduplicates_by_latest(self) -> None:
client = MagicMock()
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_PENDING, "2026-01-01T00:00:00Z"),
_status("CI / quality (pull_request)", CI_SUCCESS, "2026-01-01T00:01:00Z"),
]
assert wait_for_ci(client, "abc123", max_wait=10) is True
def test_only_non_ci_contexts_waits_then_ci_appears(self) -> None:
client = MagicMock()
client.get_commit_status.side_effect = [
[_status("Auto-merge / merge (pull_request)", CI_PENDING)],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def _mock_pr(sha: str = "abc123def456") -> dict[str, object]:
return {"head": {"sha": sha}}
def _mock_ci_passing() -> list[dict[str, str]]:
return [_status("CI / quality (pull_request)", CI_SUCCESS)]
class TestMain: class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient") @patch("scripts.auto_merge.GiteaClient")
def test_successful_flow_with_label_arg(self, mock_client_cls: MagicMock) -> None: def test_successful_flow_with_label_arg(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
main, main,
@@ -91,6 +184,8 @@ class TestMain:
"""Label not passed via arg, but PR has ready-to-merge via API.""" """Label not passed via arg, but PR has ready-to-merge via API."""
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -123,6 +218,8 @@ class TestMain:
"""Gitea Actions doesn't populate label name, but API shows ready-to-merge.""" """Gitea Actions doesn't populate label name, but API shows ready-to-merge."""
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -166,6 +263,8 @@ class TestMain:
def test_merge_pr_failure_raises_click(self, mock_client_cls: MagicMock) -> None: def test_merge_pr_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
@@ -178,9 +277,41 @@ class TestMain:
def test_merge_pr_json_parse_failure(self, mock_client_cls: MagicMock) -> None: def test_merge_pr_json_parse_failure(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = _mock_ci_passing()
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway") mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"]) result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 1 assert result.exit_code == 1
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_ci_failure_blocks_merge(self, mock_client_cls: MagicMock) -> None:
"""CI checks fail — merge should not be attempted."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = _mock_pr()
mock_client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_FAILURE),
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 1
assert "CI checks did not pass" in result.output
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
def test_no_sha_proceeds_without_wait(self, mock_client_cls: MagicMock) -> None:
"""PR head SHA missing — should proceed without waiting."""
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
mock_client.get_pr.return_value = {"head": {}}
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "fix: bug", "owner/repo", "1"])
assert result.exit_code == 0
assert "squash-merged" in result.output
+1 -56
View File
@@ -29,7 +29,6 @@ class TestCLI:
name=None, name=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker",
admin_token="", admin_token="",
integration_retries=3, integration_retries=3,
labels=None, labels=None,
@@ -51,7 +50,6 @@ class TestCLI:
name=None, name=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker",
admin_token="", admin_token="",
integration_retries=3, integration_retries=3,
labels=None, labels=None,
@@ -91,7 +89,6 @@ class TestCLI:
name=None, name=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker",
admin_token=None, admin_token=None,
integration_retries=3, integration_retries=3,
labels=None, labels=None,
@@ -141,7 +138,6 @@ class TestCLI:
name="r1", name="r1",
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker",
admin_token="", admin_token="",
integration_retries=3, integration_retries=3,
labels=None, labels=None,
@@ -163,29 +159,6 @@ class TestCLI:
name=None, name=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode="docker",
admin_token="",
integration_retries=3,
labels=None,
ask_become_pass=True,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_install_binary_mode(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--mode", "binary"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
name=None,
token="tok",
gitea_url="https://git.example.com",
mode="binary",
admin_token="", admin_token="",
integration_retries=3, integration_retries=3,
labels=None, labels=None,
@@ -230,7 +203,6 @@ class TestCLI:
user="ubuntu", user="ubuntu",
key="/key", key="/key",
version="v0.2.0", version="v0.2.0",
mode="docker",
ask_become_pass=True, ask_become_pass=True,
) )
@@ -247,24 +219,6 @@ class TestCLI:
user="ubuntu", user="ubuntu",
key=None, key=None,
version=None, version=None,
mode="docker",
ask_become_pass=True,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_update_binary_mode(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["update", "host1", "--user", "ubuntu", "--mode", "binary"])
assert result.exit_code == 0
mock_manager.update.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
version=None,
mode="binary",
ask_become_pass=True, ask_become_pass=True,
) )
@@ -294,7 +248,6 @@ class TestCLI:
host=None, host=None,
user=None, user=None,
key=None, key=None,
mode=None,
ask_become_pass=True, ask_become_pass=True,
) )
@@ -304,14 +257,13 @@ class TestCLI:
mock_manager_class.return_value = mock_manager mock_manager_class.return_value = mock_manager
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["start", "r1", "--host", "newhost", "--user", "newuser", "--mode", "binary"]) result = runner.invoke(cli, ["start", "r1", "--host", "newhost", "--user", "newuser"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_manager.start.assert_called_once_with( mock_manager.start.assert_called_once_with(
name="r1", name="r1",
host="newhost", host="newhost",
user="newuser", user="newuser",
key=None, key=None,
mode="binary",
ask_become_pass=True, ask_become_pass=True,
) )
@@ -362,7 +314,6 @@ class TestCLI:
key=None, key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode=None,
ask_become_pass=True, ask_become_pass=True,
) )
@@ -396,7 +347,6 @@ class TestCLI:
key=None, key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode=None,
ask_become_pass=True, ask_become_pass=True,
) )
@@ -426,7 +376,6 @@ class TestCLI:
host=None, host=None,
user=None, user=None,
key=None, key=None,
mode=None,
ask_become_pass=True, ask_become_pass=True,
) )
@@ -445,7 +394,6 @@ class TestCLI:
key=None, key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode=None,
force=False, force=False,
ask_become_pass=True, ask_become_pass=True,
) )
@@ -465,7 +413,6 @@ class TestCLI:
key=None, key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode=None,
force=True, force=True,
ask_become_pass=True, ask_become_pass=True,
) )
@@ -552,7 +499,6 @@ class TestCLI:
key=None, key=None,
token="tok", token="tok",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
mode=None,
force=False, force=False,
ask_become_pass=True, ask_become_pass=True,
) )
@@ -578,7 +524,6 @@ class TestCLI:
"name": "r1", "name": "r1",
"host": "10.0.0.1", "host": "10.0.0.1",
"user": "ubuntu", "user": "ubuntu",
"mode": "docker",
"labels": "docker:docker://alpine:latest", "labels": "docker:docker://alpine:latest",
"status": "active", "status": "active",
}, },
+1 -1
View File
@@ -47,7 +47,7 @@ class TestConfigConstants:
assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master" assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master"
assert BRANCH_PROTECTION_CONFIG["enable_push"] is False assert BRANCH_PROTECTION_CONFIG["enable_push"] is False
assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 1 assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 1
assert BRANCH_PROTECTION_CONFIG["status_check_contexts"] == ["lint", "unit-tests", "molecule-tests"] assert BRANCH_PROTECTION_CONFIG["status_check_contexts"] == ["CI / quality", "CI / molecule-tests*"]
def test_label_config(self) -> None: def test_label_config(self) -> None:
assert LABEL_CONFIG["name"] == "ready-to-merge" assert LABEL_CONFIG["name"] == "ready-to-merge"
+95 -28
View File
@@ -8,9 +8,12 @@ import pytest
from scripts.distribute_molecule import ( from scripts.distribute_molecule import (
MOLECULE_ROOT, MOLECULE_ROOT,
PLATFORMS,
TestPair,
build_pairs,
discover_scenarios, discover_scenarios,
distribute, distribute,
scenarios_for_runner, pairs_for_runner,
) )
@@ -33,47 +36,97 @@ class TestDiscoverScenarios:
assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT
class TestPairEncoding:
def test_encode_roundtrip(self) -> None:
pair = TestPair("default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""})
encoded = pair.encode()
assert encoded == "default|ubuntu-2204|ubuntu:22.04|"
decoded = TestPair.decode(encoded)
assert decoded.scenario == "default"
assert decoded.platform["name"] == "ubuntu-2204"
assert decoded.platform["image"] == "ubuntu:22.04"
assert decoded.platform["command"] == ""
def test_encode_with_command(self) -> None:
pair = TestPair(
"default",
{"name": "archlinux", "image": "archlinux:latest", "command": "/usr/lib/systemd/systemd"},
)
encoded = pair.encode()
assert encoded == "default|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
decoded = TestPair.decode(encoded)
assert decoded.platform["command"] == "/usr/lib/systemd/systemd"
class TestBuildPairs:
def test_cross_product(self) -> None:
scenarios = ["a", "b"]
platforms = [
{"name": "p1", "image": "img1", "command": ""},
{"name": "p2", "image": "img2", "command": ""},
]
pairs = build_pairs(scenarios, platforms)
assert len(pairs) == 4
assert pairs[0].scenario == "a"
assert pairs[0].platform["name"] == "p1"
assert pairs[1].scenario == "a"
assert pairs[1].platform["name"] == "p2"
assert pairs[2].scenario == "b"
assert pairs[2].platform["name"] == "p1"
assert pairs[3].scenario == "b"
assert pairs[3].platform["name"] == "p2"
def test_default_platforms(self) -> None:
pairs = build_pairs(["default"])
assert len(pairs) == len(PLATFORMS)
assert all(p.scenario == "default" for p in pairs)
class TestDistribute: class TestDistribute:
def test_even_split(self) -> None: def test_even_split(self) -> None:
scenarios = ["a", "b", "c", "d", "e", "f"] pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
groups = distribute(scenarios, 3) groups = distribute(pairs, 3)
assert groups == [["a", "d"], ["b", "e"], ["c", "f"]] assert len(groups) == 3
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 2
def test_uneven_split(self) -> None: def test_uneven_split(self) -> None:
scenarios = ["a", "b", "c", "d", "e"] pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(5)]
groups = distribute(scenarios, 3) groups = distribute(pairs, 3)
assert groups == [["a", "d"], ["b", "e"], ["c"]] assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_scenarios(self) -> None: def test_more_runners_than_pairs(self) -> None:
scenarios = ["a", "b"] pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
groups = distribute(scenarios, 5) groups = distribute(pairs, 5)
assert groups == [["a"], ["b"], [], [], []] assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_single_runner(self) -> None: def test_empty_pairs(self) -> None:
scenarios = ["a", "b", "c"]
groups = distribute(scenarios, 1)
assert groups == [["a", "b", "c"]]
def test_empty_scenarios(self) -> None:
groups = distribute([], 3) groups = distribute([], 3)
assert groups == [[], [], []] assert groups == [[], [], []]
class TestScenariosForRunner: class TestPairsForRunner:
def test_returns_correct_subset(self) -> None: def test_returns_correct_subset(self) -> None:
scenarios = ["a", "b", "c", "d", "e", "f"] pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
assert scenarios_for_runner(scenarios, 0, 3) == ["a", "d"] assert len(pairs_for_runner(pairs, 0, 3)) == 2
assert scenarios_for_runner(scenarios, 1, 3) == ["b", "e"] assert len(pairs_for_runner(pairs, 1, 3)) == 2
assert scenarios_for_runner(scenarios, 2, 3) == ["c", "f"] assert len(pairs_for_runner(pairs, 2, 3)) == 2
def test_out_of_range_raises(self) -> None: def test_out_of_range_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc: with pytest.raises(click.ClickException) as exc:
scenarios_for_runner(["a"], 5, 3) pairs_for_runner(pairs, 5, 3)
assert "out of range" in str(exc.value) assert "out of range" in str(exc.value)
def test_negative_index_raises(self) -> None: def test_negative_index_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc: with pytest.raises(click.ClickException) as exc:
scenarios_for_runner(["a"], -1, 3) pairs_for_runner(pairs, -1, 3)
assert "out of range" in str(exc.value) assert "out of range" in str(exc.value)
@@ -93,6 +146,19 @@ class TestCli:
assert "alpha" in result.output assert "alpha" in result.output
assert "beta" in result.output assert "beta" in result.output
def test_list_platforms_flag(self) -> None:
from click.testing import CliRunner
from scripts.distribute_molecule import cli
runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2204" in result.output
assert "ubuntu-2404" in result.output
assert "debian-12" in result.output
assert "archlinux" in result.output
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None: def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner from click.testing import CliRunner
@@ -115,13 +181,14 @@ class TestCli:
from scripts.distribute_molecule import cli from scripts.distribute_molecule import cli
root = tmp_path / "molecule" root = tmp_path / "molecule"
for s in ["a", "b", "c"]: (root / "alpha").mkdir(parents=True)
(root / s).mkdir(parents=True)
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root): with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"]) result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output.strip() == "b" # Output should contain encoded pairs with platform info
assert "alpha|" in result.output
assert "ubuntu-2204" in result.output
def test_main_module_block() -> None: def test_main_module_block() -> None:
-2
View File
@@ -22,7 +22,6 @@ class TestRunnerRegistry:
host="10.0.0.1", host="10.0.0.1",
user="ubuntu", user="ubuntu",
key="/key", key="/key",
mode="docker",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
labels="docker:docker://alpine:latest", labels="docker:docker://alpine:latest",
) )
@@ -31,7 +30,6 @@ class TestRunnerRegistry:
assert info["host"] == "10.0.0.1" assert info["host"] == "10.0.0.1"
assert info["user"] == "ubuntu" assert info["user"] == "ubuntu"
assert info["key"] == "/key" assert info["key"] == "/key"
assert info["mode"] == "docker"
assert info["gitea_url"] == "https://git.example.com" assert info["gitea_url"] == "https://git.example.com"
assert info["labels"] == "docker:docker://alpine:latest" assert info["labels"] == "docker:docker://alpine:latest"
assert "created_at" in info assert "created_at" in info
+22 -133
View File
@@ -33,14 +33,12 @@ class TestRunnerManager:
assert "registration_token=tok" in cmd_str assert "registration_token=tok" in cmd_str
assert "runner_name=192.168.1.10" in cmd_str assert "runner_name=192.168.1.10" in cmd_str
assert "gitea_url=https://git.example.com" in cmd_str assert "gitea_url=https://git.example.com" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "Installing Gitea Runner on 192.168.1.10" in mock_executor.run.call_args.kwargs["description"] assert "Installing Gitea Runner on 192.168.1.10" in mock_executor.run.call_args.kwargs["description"]
mock_registry.add.assert_called_once_with( mock_registry.add.assert_called_once_with(
name="192.168.1.10", name="192.168.1.10",
host="192.168.1.10", host="192.168.1.10",
user="ubuntu", user="ubuntu",
key=None, key=None,
mode="docker",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
labels=None, labels=None,
) )
@@ -60,14 +58,12 @@ class TestRunnerManager:
assert "/key" in cmd_str assert "/key" in cmd_str
assert "registration_token=preset" in cmd_str assert "registration_token=preset" in cmd_str
assert "runner_name=my-runner" in cmd_str assert "runner_name=my-runner" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "--ask-become-pass" not in cmd_str assert "--ask-become-pass" not in cmd_str
mock_registry.add.assert_called_once_with( mock_registry.add.assert_called_once_with(
name="my-runner", name="my-runner",
host="host1", host="host1",
user="root", user="root",
key="/key", key="/key",
mode="docker",
gitea_url="https://git.example.com", gitea_url="https://git.example.com",
labels=None, labels=None,
) )
@@ -82,27 +78,6 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0] cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "--ask-become-pass" in cmd_str assert "--ask-become-pass" in cmd_str
assert "runner_mode=docker" in cmd_str
def test_install_binary_mode(self) -> None:
mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
manager.install("host1", "root", token="tok", gitea_url="https://git.example.com", mode="binary")
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "runner_mode=binary" in cmd_str
mock_registry.add.assert_called_once_with(
name="host1",
host="host1",
user="root",
key=None,
mode="binary",
gitea_url="https://git.example.com",
labels=None,
)
def test_install_missing_gitea_url(self) -> None: def test_install_missing_gitea_url(self) -> None:
manager = RunnerManager() manager = RunnerManager()
@@ -181,7 +156,6 @@ class TestRunnerManager:
assert "--private-key" in cmd_str assert "--private-key" in cmd_str
assert "/key" in cmd_str assert "/key" in cmd_str
assert "gitea_runner_version=v0.2.0" in cmd_str assert "gitea_runner_version=v0.2.0" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "--ask-become-pass" not in cmd_str assert "--ask-become-pass" not in cmd_str
assert "Updating Gitea Runner on host" in mock_executor.run.call_args.kwargs["description"] assert "Updating Gitea Runner on host" in mock_executor.run.call_args.kwargs["description"]
@@ -194,17 +168,6 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0] cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "--ask-become-pass" in cmd_str assert "--ask-become-pass" in cmd_str
assert "runner_mode=docker" in cmd_str
def test_update_binary_mode(self) -> None:
manager = RunnerManager()
mock_executor = MagicMock()
manager._executor = mock_executor
manager.update("host", "user", mode="binary")
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "runner_mode=binary" in cmd_str
def test_update_playbook_not_found(self) -> None: def test_update_playbook_not_found(self) -> None:
manager = RunnerManager() manager = RunnerManager()
@@ -226,15 +189,13 @@ class TestRunnerManager:
"host": "10.0.0.1", "host": "10.0.0.1",
"user": "ubuntu", "user": "ubuntu",
"key": "/key", "key": "/key",
"mode": "docker",
"gitea_url": "https://git.example.com", "gitea_url": "https://git.example.com",
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
host, user, key, mode, gitea_url = manager._resolve_runner("r1") host, user, key, gitea_url = manager._resolve_runner("r1")
assert host == "10.0.0.1" assert host == "10.0.0.1"
assert user == "ubuntu" assert user == "ubuntu"
assert key == "/key" assert key == "/key"
assert mode == "docker"
assert gitea_url == "https://git.example.com" assert gitea_url == "https://git.example.com"
mock_registry.get.assert_called_once_with("r1") mock_registry.get.assert_called_once_with("r1")
@@ -242,11 +203,10 @@ class TestRunnerManager:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.get.return_value = None mock_registry.get.return_value = None
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
host, user, key, mode, gitea_url = manager._resolve_runner("r1", host="10.0.0.2", user="root") host, user, key, gitea_url = manager._resolve_runner("r1", host="10.0.0.2", user="root")
assert host == "10.0.0.2" assert host == "10.0.0.2"
assert user == "root" assert user == "root"
assert key is None assert key is None
assert mode == "docker"
assert gitea_url == "" assert gitea_url == ""
def test_resolve_runner_override(self) -> None: def test_resolve_runner_override(self) -> None:
@@ -255,17 +215,13 @@ class TestRunnerManager:
"host": "10.0.0.1", "host": "10.0.0.1",
"user": "ubuntu", "user": "ubuntu",
"key": "/key", "key": "/key",
"mode": "docker",
"gitea_url": "https://git.example.com", "gitea_url": "https://git.example.com",
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
host, user, key, mode, gitea_url = manager._resolve_runner( host, user, key, gitea_url = manager._resolve_runner("r1", host="10.0.0.2", user="root", key="/new")
"r1", host="10.0.0.2", user="root", key="/new", mode="binary"
)
assert host == "10.0.0.2" assert host == "10.0.0.2"
assert user == "root" assert user == "root"
assert key == "/new" assert key == "/new"
assert mode == "binary"
assert gitea_url == "https://git.example.com" assert gitea_url == "https://git.example.com"
def test_resolve_runner_not_found(self) -> None: def test_resolve_runner_not_found(self) -> None:
@@ -277,7 +233,7 @@ class TestRunnerManager:
def test_start(self) -> None: def test_start(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -287,22 +243,20 @@ class TestRunnerManager:
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "start-runner.yml" in cmd_str assert "start-runner.yml" in cmd_str
assert "runner_name=r1" in cmd_str assert "runner_name=r1" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "Starting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] assert "Starting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_start_with_override(self) -> None: def test_start_with_override(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
manager.start("r1", host="newhost", user="newuser", mode="binary") manager.start("r1", host="newhost", user="newuser")
cmd = mock_executor.run.call_args.args[0] cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "newhost," in cmd_str assert "newhost," in cmd_str
assert "newuser" in cmd_str assert "newuser" in cmd_str
assert "runner_mode=binary" in cmd_str
def test_start_no_registry_with_explicit(self) -> None: def test_start_no_registry_with_explicit(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
@@ -311,14 +265,14 @@ class TestRunnerManager:
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
manager.start("r1", host="host", user="user", mode="docker") manager.start("r1", host="host", user="user")
cmd = mock_executor.run.call_args.args[0] cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "start-runner.yml" in cmd_str assert "start-runner.yml" in cmd_str
def test_stop(self) -> None: def test_stop(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -332,7 +286,7 @@ class TestRunnerManager:
def test_enable(self) -> None: def test_enable(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "docker"} mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -350,7 +304,6 @@ class TestRunnerManager:
"host": "host", "host": "host",
"user": "user", "user": "user",
"key": None, "key": None,
"mode": "docker",
"gitea_url": "", "gitea_url": "",
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
@@ -364,7 +317,6 @@ class TestRunnerManager:
assert "runner_name=r1" in cmd_str assert "runner_name=r1" in cmd_str
assert "registration_token=tok" in cmd_str assert "registration_token=tok" in cmd_str
assert "gitea_url=https://git.example.com" in cmd_str assert "gitea_url=https://git.example.com" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "Disabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] assert "Disabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_disable_uses_registry_gitea_url(self) -> None: def test_disable_uses_registry_gitea_url(self) -> None:
@@ -373,7 +325,6 @@ class TestRunnerManager:
"host": "host", "host": "host",
"user": "user", "user": "user",
"key": None, "key": None,
"mode": "docker",
"gitea_url": "https://registry.example.com", "gitea_url": "https://registry.example.com",
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
@@ -401,7 +352,7 @@ class TestRunnerManager:
def test_status(self) -> None: def test_status(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None, "mode": "binary"} mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
manager._executor = mock_executor manager._executor = mock_executor
@@ -411,7 +362,6 @@ class TestRunnerManager:
cmd_str = " ".join(cmd) cmd_str = " ".join(cmd)
assert "status-runner.yml" in cmd_str assert "status-runner.yml" in cmd_str
assert "runner_name=r1" in cmd_str assert "runner_name=r1" in cmd_str
assert "runner_mode=binary" in cmd_str
assert "Checking status of Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"] assert "Checking status of Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_remove(self) -> None: def test_remove(self) -> None:
@@ -420,7 +370,6 @@ class TestRunnerManager:
"host": "host", "host": "host",
"user": "user", "user": "user",
"key": None, "key": None,
"mode": "docker",
"gitea_url": "", "gitea_url": "",
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
@@ -434,7 +383,6 @@ class TestRunnerManager:
assert "runner_name=r1" in cmd_str assert "runner_name=r1" in cmd_str
assert "registration_token=tok" in cmd_str assert "registration_token=tok" in cmd_str
assert "gitea_url=https://git.example.com" in cmd_str assert "gitea_url=https://git.example.com" in cmd_str
assert "runner_mode=docker" in cmd_str
assert "Removing Gitea Runner r1 from host" in mock_executor.run.call_args.kwargs["description"] assert "Removing Gitea Runner r1 from host" in mock_executor.run.call_args.kwargs["description"]
mock_registry.remove.assert_called_once_with("r1") mock_registry.remove.assert_called_once_with("r1")
@@ -470,7 +418,6 @@ class TestRunnerManager:
"host": "host", "host": "host",
"user": "user", "user": "user",
"key": None, "key": None,
"mode": "docker",
"gitea_url": "", "gitea_url": "",
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
@@ -493,14 +440,14 @@ class TestRunnerManager:
mock_executor.run.assert_not_called() mock_executor.run.assert_not_called()
mock_registry.remove.assert_called_once_with("r1") mock_registry.remove.assert_called_once_with("r1")
def test_list_runners_docker(self) -> None: def test_list_runners(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.list.return_value = { mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": "/key", "mode": "docker"}, "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": "/key"},
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "running" mock_executor.run_ad_hoc.return_value = "active"
manager._executor = mock_executor manager._executor = mock_executor
runners = manager.list_runners() runners = manager.list_runners()
@@ -513,44 +460,16 @@ class TestRunnerManager:
"ubuntu", "ubuntu",
"/key", "/key",
"shell", "shell",
( "sudo -u grm-r1 systemctl --user is-active gitea-runner 2>/dev/null",
"docker inspect gitea-runner-r1 2>/dev/null | "
"python3 -c \"import sys,json; print(json.load(sys.stdin)[0]['State']['Status'])\" "
"2>/dev/null || echo unknown"
),
become=True, become=True,
ask_become_pass=True, ask_become_pass=True,
check=False, check=False,
) )
def test_list_runners_binary(self) -> None: def test_list_runners_exception(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.list.return_value = { mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": "/key", "mode": "binary"}, "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "active"
manager._executor = mock_executor
runners = manager.list_runners()
assert len(runners) == 1
assert runners[0]["status"] == "active"
mock_executor.run_ad_hoc.assert_called_once_with(
"10.0.0.1",
"ubuntu",
"/key",
"shell",
"systemctl is-active gitea-runner@r1 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
)
def test_list_runners_binary_exception(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "binary"},
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
@@ -569,7 +488,7 @@ class TestRunnerManager:
def test_list_runners_without_labels(self) -> None: def test_list_runners_without_labels(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.list.return_value = { mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"}, "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
@@ -579,38 +498,23 @@ class TestRunnerManager:
runners = manager.list_runners() runners = manager.list_runners()
assert runners[0]["labels"] == "" assert runners[0]["labels"] == ""
def test_list_runners_docker_exited(self) -> None: def test_list_runners_inactive(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.list.return_value = { mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"}, "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "exited" mock_executor.run_ad_hoc.return_value = "inactive"
manager._executor = mock_executor manager._executor = mock_executor
runners = manager.list_runners() runners = manager.list_runners()
assert runners[0]["status"] == "inactive" assert runners[0]["status"] == "inactive"
def test_list_runners_docker_fallback_to_host_container(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
# First call (name-based) returns unknown, second (host-based) returns running
mock_executor.run_ad_hoc.side_effect = ["unknown", "running"]
manager._executor = mock_executor
runners = manager.list_runners()
assert runners[0]["status"] == "active"
assert mock_executor.run_ad_hoc.call_count == 2
def test_list_runners_unknown_status(self) -> None: def test_list_runners_unknown_status(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.list.return_value = { mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"}, "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
@@ -620,25 +524,10 @@ class TestRunnerManager:
runners = manager.list_runners() runners = manager.list_runners()
assert runners[0]["status"] == "unknown" assert runners[0]["status"] == "unknown"
def test_list_runners_docker_first_command_falls_back(self) -> None:
"""When the first docker inspect raises, the loop continues to the next fallback."""
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.side_effect = [AnsibleError("ssh fail"), "running"]
manager._executor = mock_executor
runners = manager.list_runners()
assert runners[0]["status"] == "active"
assert mock_executor.run_ad_hoc.call_count == 2
def test_list_runners_ad_hoc_error(self) -> None: def test_list_runners_ad_hoc_error(self) -> None:
mock_registry = MagicMock() mock_registry = MagicMock()
mock_registry.list.return_value = { mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "mode": "docker"}, "r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
} }
manager = RunnerManager(registry=mock_registry) manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock() mock_executor = MagicMock()
Generated
+1301
View File
File diff suppressed because it is too large Load Diff