Files
grm/README.md
T
emilandemo c72dc97f63
Post-merge / detect-and-configure (push) Successful in 1m8s
Post-merge / release-and-maintain (push) Successful in 1m9s
GRM-158: feat: use Gitea mirror for Ansible collection installs
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-08 23:36:41 +00:00

22 KiB

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).

CI License: GPL-3.0 Coverage Tests Docs Code Quality Version Python

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 install twice 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 CLIinstall, update, start, stop, enable, disable, status, remove, list — all from a single grm command.
  • Automatic integration testing — Every installation runs an integration test that verifies the .runner registration 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_LANG environment variable.
  • Security-conscious — Secrets (registration tokens) are passed via temporary JSON files with 0600 permissions, 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. The master branch may contain unreleased changes that are not yet stable. The command above automatically selects the most recent tagged release. To see all available releases, run git tag --sort=-version:refname or 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 to ansible-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

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:

  1. Verifies Python 3.12+ is installed
  2. Creates a virtualenv in .venv
  3. Installs all Python dependencies (including Ansible, Click, python-dotenv)
  4. Creates .env from .env.example if not present
  5. Installs development tools (actionlint, git-cliff, act_runner, checkmake)
  6. 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:

  1. --become-password-file <path> (CLI flag, global) — Read sudo password from a file. Works for all commands including grm list.
  2. GRM_BECOME_PASSWORD_FILE (env var) — Same as above, set in .env or environment.
  3. ANSIBLE_BECOME_PASSWORD_FILE (env var) — Fallback, Ansible-native env var.
  4. Interactive prompt — If none of the above are set, GRM prompts for the sudo password (hidden input).
  5. Piped stdin — When stdin is not a TTY, reads the first line: echo 'password' | grm list.
  6. --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 (overrides GITEA_RUNNER_LABELS env var).
  • No --labels flag — Uses GITEA_RUNNER_LABELS env 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 0600 permissions 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=True with 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:

  1. Python CLI (src/grm/) — Built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the ansible-playbook subprocess. Secrets are passed via temporary JSON files to avoid exposure in the process list.

  2. Ansible Role (ansible/roles/gitea_runner/) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer.

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

Technical Documentation

License

GPL-3.0 — See LICENSE for the full text.