Co-authored-by: kireto <kireto@oblachno.com>
Gitea Runner Manager (GRM)
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
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. GRM handles the entire runner lifecycle — from initial installation and registration with Gitea, through start/stop/enable/disable operations, to clean removal with deregistration.
Pronunciation: GRM is short for Gitea Runner Manager, but say it like ГРЪМ (roughly "GRUM") — the Bulgarian word for thunder. An open-source project from Oblachno (облачно means cloudy in Bulgarian).
Why GRM?
Managing Gitea Actions runners manually is tedious and error-prone: you need to create system users, set up rootless Docker, download and configure the runner binary, register it with Gitea, create systemd services, and set up Docker prune timers — all per runner instance. GRM automates this entire process with a single command, and ensures it is idempotent (safe to re-run).
Key problems GRM solves:
- Isolation without root: Each runner operates under a dedicated system user with its own rootless Docker daemon, so runners on the same host never interfere with each other or with the host's Docker installation.
- Reproducible setup: The Ansible role is idempotent — running
grm installtwice produces zero changes on the second run, so it is safe for CI/CD pipelines and configuration management. - Full lifecycle management: Install, start, stop, enable (boot persistence), disable (deregister), update the binary, check status, and remove — all from one CLI.
- Local registry: GRM stores connection metadata locally, so after installation you manage runners by name alone without repeating SSH credentials.
Features
- Rootless Docker isolation — Each runner gets its own rootless Docker daemon under a dedicated system user (
grm-<name>), with its own Docker socket at/run/user/<UID>/docker.sock. - Multi-instance support — Install and manage multiple isolated runners on the same host, each with independent users, data directories, and systemd user services.
- Idempotent Ansible role — Safe to re-run; the role detects existing state and only applies changes when needed.
- Full lifecycle CLI —
install,update,start,stop,enable,disable,status,remove,list— all from a singlegrmcommand. - Automatic integration testing — Every installation runs an integration test that verifies the
.runnerregistration file and systemd service state. - Docker prune automation — A systemd user timer automatically prunes old Docker images and volumes on a daily schedule.
- Local runner registry — Connection details are stored in
~/.local/share/grm/runners.json, so lifecycle commands work by runner name alone. - Internationalisation — Console messages support English, Bulgarian, German, Russian, Chinese, and Polish via the
GRM_LANGenvironment variable. - Security-conscious — Secrets (registration tokens) are passed via temporary JSON files with
0600permissions, never on the command line (CWE-214). - Comprehensive CI/CD — 100% test coverage, automated releases via conventional commits and git-cliff, Molecule tests across 4 OS platforms.
Quick Start
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
make setup
cp .env.example .env # Edit with your Gitea URL and tokens
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
Important: Always checkout the latest release tag before running
make setup. Themasterbranch may contain unreleased changes that are not yet stable. The command above automatically selects the most recent tagged release. To see all available releases, rungit tag --sort=-version:refnameor check the releases page.
Tokens: You need two tokens from your Gitea instance — a registration token to register runners, and an admin API token for optional post-install verification. See Getting Started for detailed setup instructions.
Prerequisites
On your local machine (where you run grm)
- Python 3.12+ — GRM targets Python 3.12 and requires it for development setup.
- Ansible — Installed automatically by
make setup(via pip). GRM delegates all remote operations toansible-playbook. - SSH access — A private key that grants access to the target host(s) as a user with sudo privileges.
On the target host(s) (where runners will be installed)
- SSH server — Reachable via the key specified with
--key. - Sudo access — The SSH user must have sudo privileges for creating system users, installing packages, and configuring rootless Docker. By default, you will be prompted for the sudo password interactively. For automation, configure passwordless sudo and pass
--no-ask-become-pass. - Docker — Installed automatically by the Ansible role (rootless mode). No pre-existing Docker installation is required.
- systemd — Required for user services and lingering. All supported OSes ship with systemd.
Installation
Option 1: From source (recommended for full control)
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Latest stable release
make setup
source .venv/bin/activate
make setup performs the following:
- Verifies Python 3.12+ is installed
- Creates a virtualenv in
.venv - Installs all Python dependencies (including Ansible, Click, python-dotenv)
- Creates
.envfrom.env.exampleif not present - Installs development tools (actionlint, git-cliff, act_runner, checkmake)
- Sets up pre-commit hooks
Option 2: Via pip (for using GRM without the full repo)
GRM is published to the Gitea PyPI registry at
https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple.
The registry is publicly readable — no authentication required to install.
Quick install (one-off):
pip install grm --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
Persistent configuration (recommended):
Add the registry to ~/.pip/pip.conf so future pip install commands find
GRM automatically:
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
Then install normally:
pip install grm
This installs the grm CLI and its Python dependencies. The Ansible playbooks
and role are bundled with the package, so grm install works out of the box.
For development or access to Make targets, clone the repository (Option 1).
Post-install configuration
After installation, create your .env file:
cp .env.example .env
# Edit .env with your Gitea URL and registration token
See the Configuration section below for details.
CLI Commands Overview
GRM provides a single grm command with subcommands for the full runner lifecycle:
| Command | Description |
|---|---|
grm install <host> |
Install and configure a runner on a remote host |
grm update <host> |
Update the Gitea Runner binary on a remote host |
grm start <name> |
Start a registered runner |
grm stop <name> |
Stop a registered runner |
grm restart <name> |
Restart a runner (stop, prune Docker images, start) |
grm enable <name> |
Enable a runner to start on boot |
grm disable <name> |
Disable and deregister a runner |
grm status <name> |
Check the status of a registered runner |
grm remove <name> |
Remove a runner entirely (with remote cleanup) |
grm remove <name> --force |
Remove only the local registry entry (skip remote cleanup) |
grm list |
List all registered runners with live status |
grm list --no-status |
List registered runners without SSH status checks |
grm health [name] |
Run health check (Docker, runner service, disk) on one or all runners |
grm trigger-workflow <workflow_id> |
Trigger a Gitea Actions workflow via the API |
grm trigger-workflow --list |
List available workflows in the repository |
grm --version |
Show the installed version |
All lifecycle commands (start, stop, restart, enable, disable, status, remove) work by runner name and pull connection details from the local registry. You can override any stored value with --host, --user, or --key.
See the CLI Commands wiki page for full argument and option reference.
Configuration
GRM reads configuration from a .env file in the current directory (loaded automatically via python-dotenv). You can also set environment variables directly.
Required variables
| Variable | Description |
|---|---|
GITEA_URL |
Your Gitea instance URL (e.g., https://git.example.com) |
GITEA_REGISTRATION_TOKEN |
Runner registration token from Gitea (starts with GR) |
Optional variables
| Variable | Default | Description |
|---|---|---|
CI_GITEA_TOKEN |
— | Gitea admin API token for optional post-install API verification |
GITEA_INTEGRATION_RETRIES |
3 |
Number of API check retries during integration test |
GITEA_RUNNER_USER |
current login | Default SSH user (overrides --user) |
GITEA_RUNNER_KEY |
— | Default SSH key path (overrides --key) |
GITEA_RUNNER_LABELS |
— | Default runner labels (overrides --labels) |
GRM_LANG |
en |
UI language: en, bg, de, ru, zh, pl |
GRM_LOG_LEVEL |
INFO |
Console log level: DEBUG, INFO, WARNING, ERROR, CRITICAL |
GRM_BECOME_PASSWORD_FILE |
— | Path to file containing sudo password (see Sudo Password Handling) |
ANSIBLE_BECOME_PASSWORD_FILE |
— | Fallback sudo password file path (Ansible-native env var) |
Sudo Password Handling
GRM delegates remote operations to Ansible, which uses sudo (become) on the target host. There are multiple ways to provide the sudo password, in priority order:
--become-password-file <path>(CLI flag, global) — Read sudo password from a file. Works for all commands includinggrm list.GRM_BECOME_PASSWORD_FILE(env var) — Same as above, set in.envor environment.ANSIBLE_BECOME_PASSWORD_FILE(env var) — Fallback, Ansible-native env var.- Interactive prompt — If none of the above are set, GRM prompts for the sudo password (hidden input).
- Piped stdin — When stdin is not a TTY, reads the first line:
echo 'password' | grm list. --no-ask-become-pass— Skip sudo password entirely (use when the target user has passwordless sudo).
For grm list specifically, the password is collected once and reused for all runner status checks via --become-password-file, avoiding stdin consumption issues when checking multiple runners.
Examples:
# Interactive prompt (default)
grm install 192.168.1.10 --user ubuntu
# Password file (recommended for automation)
echo 'my-sudo-pass' > ~/.grm-sudo-pass
chmod 600 ~/.grm-sudo-pass
grm --become-password-file ~/.grm-sudo-pass install 192.168.1.10 --user ubuntu
# Env var (set in .env)
GRM_BECOME_PASSWORD_FILE=~/.grm-sudo-pass
grm list # uses the file automatically
# Piped stdin (for scripts)
echo 'my-sudo-pass' | grm list
# Passwordless sudo on target
grm install 192.168.1.10 --user ubuntu --no-ask-become-pass
Verbose Output
Pass -v / --verbose (global flag, before the subcommand) to enable Ansible verbose mode (-v):
grm --verbose install 192.168.1.10 --user ubuntu
grm -v status prod-runner
Runner Labels
Runner labels control which jobs a runner accepts. They are set at installation time:
--labels "docker:docker://alpine:latest"— Set specific labels.--labels ""— Explicitly set no labels (overridesGITEA_RUNNER_LABELSenv var).- No
--labelsflag — UsesGITEA_RUNNER_LABELSenv var if set, otherwise the Ansible role default.
# Custom labels
grm install 192.168.1.10 --user ubuntu --labels "docker:docker://alpine:latest,ubuntu-22.04:docker://ubuntu:22.04"
# Explicitly no labels (overrides GITEA_RUNNER_LABELS env var)
grm install 192.168.1.10 --user ubuntu --labels ""
# Use GITEA_RUNNER_LABELS from .env (or role default if unset)
grm install 192.168.1.10 --user ubuntu
Getting tokens
Registration token (required): Navigate to your Gitea instance:
- Instance-level: Site Administration → Actions → Runners → Create Registration Token
- Organization-level: Organization → Settings → Actions → Runners → Create Registration Token
- Repository-level: Repository → Settings → Actions → Runners → Create Registration Token
Use instance-level tokens for shared runners, and repo-level tokens for dedicated runners.
Admin API token (optional): Settings → Applications → Generate New Token, with the admin scope (or at minimum: read:user, read:repository, read:admin). When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational and does not affect pass/fail.
Multi-Instance Support
One of GRM's core features is the ability to run multiple isolated runners on the same host. Each runner instance gets:
- Dedicated system user:
grm-<name>with its own home directory at/home/grm-<name>/ - Rootless Docker daemon: Isolated Docker socket at
/run/user/<UID>/docker.sock - Data directory:
/var/lib/gitea_runner/<name>/ - Config directory:
/etc/gitea_runner/<name>/ - Systemd user service:
gitea-runner.service(independent start/stop/enable) - Docker prune timer: Per-instance daily cleanup
# 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 build-runner
# Manage them independently by name
grm stop workflow-runner
grm status build-runner
grm list
Security Model
GRM is designed with security as a first-class concern:
- Rootless Docker: Each runner operates under a dedicated unprivileged system user. The Docker daemon runs in rootless mode, so containers never have root access to the host. User namespaces (
subuid/subgid) are configured automatically. - Dedicated users: Each runner gets its own system user (
grm-<name>) with lingering enabled, so the user's systemd services run without an active login session. - Secret handling: Registration tokens and admin tokens are never passed on the command line. They are written to temporary JSON files with
0600permissions and passed to Ansible via--extra-vars @tempfile. The temp file is deleted immediately after execution. This prevents secrets from being visible in the process list (ps aux), addressing CWE-214. - No shell injection: The CLI never uses
shell=Truewith subprocess. All Ansible commands are constructed as argument lists. - Bandit security scan: The CI pipeline runs Bandit on every PR to catch common Python security issues.
Supported Operating Systems
GRM supports and tests the following operating systems:
| OS | Versions | Package manager |
|---|---|---|
| Arch Linux | rolling | pacman |
| Ubuntu | 22.04, 24.04 | apt |
| Debian | 12 | apt |
All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files. The platform matrix is defined in devx.molecule.platforms as the single source of truth.
Development Setup
GRM uses a comprehensive development setup with 100% test coverage enforcement, multiple linters, and Molecule integration tests.
Quick development setup
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Latest stable release
make setup
source .venv/bin/activate
Make targets
| Target | Description |
|---|---|
make setup |
Full setup: venv, deps, hooks, CI tools |
make lint-all |
ruff + pyright + bandit + ansible-lint + checkmake + actionlint |
make pytest-cov |
Unit tests with 100% coverage enforcement |
make test-unit |
Unit tests without coverage |
make molecule |
All 6 Molecule scenarios on Ubuntu 22.04 |
make molecule-all |
All 6 scenarios on all 4 supported OSes |
make test-all |
pytest-cov + molecule |
make workflow-lint |
Static lint of workflow YAML (actionlint) |
make workflow-dryrun |
Dry-run all workflows in Docker |
make workflow-check |
workflow-lint + workflow-dryrun |
See the Development Setup wiki page for full details.
Architecture Overview
GRM consists of two layers:
-
Python CLI (
src/grm/) — Built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via theansible-playbooksubprocess. Secrets are passed via temporary JSON files to avoid exposure in the process list. -
Ansible Role (
ansible/roles/gitea_runner/) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer.
grm install <host>
└── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml
└── role: gitea_runner
├── user_setup.yml (create per-runner system user + lingering)
├── rootless_docker.yml (rootless Docker setup under runner user)
├── install_runner.yml (download binary, config, register, service)
├── prune.yml (Docker prune timer)
└── integration_test.yml (validate service is active)
Python modules
| Module | Description |
|---|---|
cli.py |
Click-based CLI entry point — defines all commands |
runner_manager.py |
Ansible orchestration + registry integration |
executor.py |
Ansible subprocess execution with log capture |
registry.py |
Local JSON runner registry at ~/.local/share/grm/runners.json |
i18n.py |
Internationalisation (en, bg, de, ru, zh, pl) |
exceptions.py |
Custom exceptions (GRMError, AnsibleError) |
logging_config.py |
Logging to ~/.local/state/grm/logs/grm.log |
report.py |
Operation report tracking with step status |
ui.py |
Colorised console output via Click |
See the Architecture wiki page for the full component diagram and data flow.
Documentation
Full documentation lives on the GRM Wiki.
User Documentation
- Getting Started — Installation, quick start, token setup, first run
- Installation — Prerequisites, setup, multiple instances
- CLI Commands — All commands with arguments and options
- Troubleshooting — Common issues and solutions
- FAQ — Frequently asked questions
Technical Documentation
- Architecture — High-level design, component interactions, data flow
- Development Setup — Environment setup, dependencies, local testing
- CI/CD Workflow — How CI works, release process, branch protection
- Testing Strategy — Unit, integration, and Molecule tests
- Decision Log — Key technical decisions and rationale
- Contributing Guide — Coding standards, PR workflow, commit rules
Links
License
GPL-3.0 — See LICENSE for the full text.