Architecture
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. - Ansible Role (
ansible/roles/gitea_runner/) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
High-Level Design
The CLI is a thin orchestration layer. It does not perform any remote operations itself — every action (install, update, start, stop, etc.) is delegated to an Ansible playbook. The CLI's responsibilities are:
- Parsing command-line arguments and options
- Loading configuration from
.env(via python-dotenv) - Resolving runner connection details from the local registry
- Writing secrets to temporary JSON files (CWE-214 mitigation)
- Constructing the
ansible-playbookcommand with appropriate inventory, user, key, and extra-vars - Capturing and streaming Ansible output to log files
- Maintaining the local runner registry (
~/.local/share/grm/runners.json) - Providing colorised console output and operation reports
The Ansible role handles all remote state: user creation, package installation, Docker configuration, binary download, runner registration, systemd service management, and Docker prune timers.
Component Tree
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)
├── healthcheck.yml (health check script + systemd timer)
└── integration_test.yml (validate service is active)
The Ansible role task execution order (from AGENTS.md):
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → healthcheck → integration_test
install_runner.ymlhandles: download, config, validate, register, servicemain.ymlhandles: prune, integration_test (NOT install_runner — avoids duplicates)systemctl --usertasks must be guarded bydocker_rootless_setup- Template creation tasks are NOT guarded by
docker_rootless_setup(they only create files)
Ansible task files
| Task file | Responsibility |
|---|---|
main.yml |
Entry point — includes all other task files in order |
systemd_check.yml |
Verifies systemd is available on the target host |
user_setup.yml |
Creates the per-runner system user, enables lingering, configures subuid/subgid, creates data and config directories |
rootless_docker.yml |
Installs Docker packages (apt for Debian/Ubuntu, pacman for Arch), provisions the rootless setup scripts on Arch (not shipped by the docker package), runs dockerd-rootless-setuptool.sh install, starts and enables the rootless Docker daemon |
install_runner.yml |
Downloads the gitea_runner binary, creates the config file, validates the binary, registers the runner with Gitea, creates and starts the systemd user service |
download_gitea_runner.yml |
Downloads the gitea_runner binary from GitHub releases |
validate.yml |
Validates the downloaded binary |
register.yml |
Registers the runner with Gitea using the registration token |
service.yml |
Creates the systemd user service file and starts/enables the service |
prune.yml |
Creates a systemd user timer for daily Docker image and volume pruning |
healthcheck.yml |
Installs a health check script and systemd timer that monitors Docker daemon, runner service, and disk space; restarts unhealthy services automatically |
integration_test.yml |
Verifies the .runner file exists and the systemd service is active; optionally queries the Gitea API |
deregister.yml |
Deregisters the runner from Gitea and removes the .runner file |
update_runner.yml |
Downloads a new version of the gitea_runner binary |
Ansible templates
| Template | Purpose |
|---|---|
gitea-runner-user.service.j2 |
Systemd user service for the gitea_runner daemon |
gitea-runner-config.yaml.j2 |
Runner configuration file (labels, capacity, log level) |
docker-prune.service.j2 |
Systemd user service for Docker pruning (oneshot) |
docker-prune.timer.j2 |
Systemd user timer triggering daily Docker prune |
runner-healthcheck.sh.j2 |
Health check script (checks Docker, runner service, disk space; restarts if down) |
runner-healthcheck.service.j2 |
Systemd user service for the health check (oneshot) |
runner-healthcheck.timer.j2 |
Systemd user timer triggering periodic health checks |
Per-Runner Isolation
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>/ - Config:
/etc/gitea_runner/<name>/ - Service:
gitea-runner.service(systemd user service) - Docker socket:
/run/user/<UID>/docker.sock(rootless, per-runner) - subuid/subgid:
grm-<name>:100000:65536(user namespace mapping)
Lingering is enabled via loginctl enable-linger so the user's systemd services run without an active login session. This is essential for runners that need to operate continuously.
Component Interactions
flowchart TD
CLI["Python CLI<br/>src/grm/<br/>(Click)"]
RM["RunnerManager<br/>runner_manager.py"]
EXEC["Executor<br/>executor.py"]
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
ANS["ansible-playbook subprocess"]
ROLE["Ansible Role<br/>ansible/roles/gitea_runner/"]
USER["user_setup.yml<br/>create system user + lingering"]
DOCKER["rootless_docker.yml<br/>rootless Docker setup"]
INSTALL["install_runner.yml<br/>download, config, register, service"]
PRUNE["prune.yml<br/>Docker prune timer"]
TEST["integration_test.yml<br/>validate service active"]
GITEA["Gitea instance<br/>registration + API"]
SYSTEMD["systemd user service<br/>gitea-runner.service"]
LOG["Log files<br/>~/.local/state/grm/logs/"]
CLI --> RM
RM --> REG
RM --> EXEC
EXEC -->|subprocess| ANS
EXEC -->|stream output| LOG
ANS --> ROLE
ROLE --> USER
ROLE --> DOCKER
ROLE --> INSTALL
ROLE --> PRUNE
ROLE --> TEST
INSTALL -->|register| GITEA
INSTALL --> SYSTEMD
DOCKER --> SYSTEMD
TEST -->|optional API check| GITEA
Data Flow
Installation flow
- User runs
grm install <host> --user <user> --key <key> --name <name> - CLI loads
.envforGITEA_URLandGITEA_REGISTRATION_TOKEN RunnerManager.install()constructs extra-vars dict with registration token, runner name, Gitea URL, and optional admin token/labels- Extra-vars are written to a temporary JSON file with
0600permissions AnsibleExecutor.run()invokesansible-playbook ansible/install-runner.ymlwith the temp file via--extra-vars @tempfile- Ansible connects to the remote host via SSH and executes the role:
- Creates system user
grm-<name>with lingering - Installs Docker packages and sets up rootless Docker
- Downloads the gitea_runner binary
- Creates the runner config file
- Registers the runner with Gitea
- Creates and starts the systemd user service
- Sets up the Docker prune timer
- Installs the health check script and systemd timer
- Runs the integration test (verifies
.runnerfile and service state)
- Creates system user
- Ansible output is streamed to a timestamped log file at
~/.local/state/grm/logs/ansible-<timestamp>.log - On success, the runner is added to the local registry at
~/.local/share/grm/runners.json - The temporary extra-vars file is deleted
Lifecycle command flow
- User runs
grm <command> <runner_name>(e.g.,grm stop prod-runner) RunnerManager._resolve_runner()looks up the runner in the local registry- If
--hostand--userare provided, they override registry values - The corresponding playbook is executed (e.g.,
stop-runner.yml) - Ansible connects to the remote host and performs the action
List command flow
- User runs
grm list RunnerManager.list_runners()reads all entries from the local registry- For each runner, an Ansible ad-hoc command checks
systemctl --user is-active gitea-runner - Results are displayed in a table with columns: NAME, HOST, USER, LABELS, STATUS
Security Model
Rootless Docker
Each runner operates under a dedicated unprivileged system user. The Docker daemon runs in rootless mode via dockerd-rootless-setuptool.sh install, which configures:
- User namespace mapping via
/etc/subuidand/etc/subgid(range: 100000-165535) - Rootless Docker socket at
/run/user/<UID>/docker.sock pastafor user-mode networking with IPv6 support (replacesslirp4netns, which lacks outgoing IPv6)fuse-overlayfsfor rootless container storage
The rootless Docker daemon is configured via a systemd user override
(docker.service.d/override.conf) that sets:
DOCKERD_ROOTLESS_ROOTLESSKIT_NET=pasta— use pasta as the network driverDOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit— pasta's native port forwardingDOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6— enable IPv6 routing
The daemon.json enables IPv6 with a ULA subnet (fd00:dead:beef::/48)
for container addressing. This ensures runner containers can reach
both IPv4 and IPv6 services (e.g., the Gitea registry) without
per-workaround DNS hacks.
Containers launched by the runner never have root access to the host. The rootless Docker daemon is started as a systemd user service and persists via lingering.
Platform-specific rootless provisioning
The rootless setup scripts (dockerd-rootless-setuptool.sh and dockerd-rootless.sh) are provided differently per OS:
- Debian/Ubuntu — shipped by the
docker-ce-rootless-extraspackage (installed via the Docker APT repo). - Arch Linux — the
dockerpackage does not include these scripts, and no official Arch package provides them. The role fetches them from the upstreammoby/mobycontrib/directory at a pinned, overridable git ref (gitea_runner_rootless_scripts_ref, defaultv28.5.1) and installs them into/usr/bin— co-located withdocker/dockerd/rootlesskit, which is required becausedockerd-rootless-setuptool.shderives itsBINdirectory from its own location and expects those binaries alongside it. Therootlesskitpackage (a required rootless runtime dependency that is not pulled in by Arch'sdockerpackage) is also installed explicitly.
Secret handling
Registration tokens and admin API tokens are never exposed on the command line. The RunnerManager._extra_vars_file() context manager:
- Creates a temporary file via
tempfile.mkstemp() - Writes the extra-vars JSON to the file
- Sets permissions to
0600(owner read/write only) - Passes the file to Ansible via
--extra-vars @tempfile - Deletes the file in a
finallyblock, even if an exception occurs
This prevents secrets from appearing 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 (list[str]), preventing shell injection attacks. The subprocess.Popen and subprocess.run calls are marked with nosec comments after security review.
Bandit security scanning
The CI pipeline runs Bandit on every PR to catch common Python security issues. The scan covers all source code in src/.
Additional Components
From AGENTS.md, the project also includes:
- devx package (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications. This package is not part of the GRM tool itself — it provides the CI/CD automation infrastructure.
- Versioning (
cliff.toml) — git-cliff configuration for automated semver versioning from conventional commits.
Python Modules
The Python CLI layer (src/grm/) consists of the following modules:
| Module | Description |
|---|---|
cli.py |
Click-based CLI entry point — defines all commands (install, update, start, stop, enable, disable, status, remove, list) |
runner_manager.py |
Ansible orchestration + registry integration — delegates to executor and manages runner lifecycle |
executor.py |
Ansible subprocess execution — runs ansible-playbook with extra-vars via temp JSON files, streams output to log files |
registry.py |
Local JSON runner registry at ~/.local/share/grm/runners.json — stores connection metadata |
i18n.py |
Internationalisation translations (en, bg, de, ru, zh, pl) — opt-in via GRM_LANG environment variable |
exceptions.py |
Custom exceptions (GRMError, AnsibleError) |
logging_config.py |
Logging configuration — writes all messages to ~/.local/state/grm/logs/grm.log at DEBUG level |
report.py |
Operation report tracking — prints a step-by-step report with status icons after each command |
ui.py |
User-facing output utilities — colorised console output via click.style, with log file always receiving plain text |
translations.json |
Translation strings for all supported languages |
Logging
GRM writes to two destinations:
| Destination | Level | Content |
|---|---|---|
| Console (stdout) | GRM_LOG_LEVEL (default: INFO) |
Colorised user-facing messages and operation reports |
~/.local/state/grm/logs/grm.log |
DEBUG | All messages with timestamps and severity |
~/.local/state/grm/logs/ansible-<timestamp>.log |
— | Full Ansible playbook output per execution |
Console output is automatically colorised via click.style: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow. The log file always captures plain text (no ANSI codes) at DEBUG level regardless of the console setting.
Set GRM_LOG_LEVEL to one of DEBUG, INFO, WARNING, ERROR, or CRITICAL to control console verbosity.