1
Architecture
Devin CI edited this page 2026-08-04 14:04:14 +00:00

Architecture

GRM consists of two layers:

  1. Python CLI (src/grm/) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the ansible-playbook subprocess.
  2. Ansible Role (ansible/roles/gitea_runner/) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.

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-playbook command 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.yml handles: download, config, validate, register, service
  • main.yml handles: prune, integration_test (NOT install_runner — avoids duplicates)
  • systemctl --user tasks must be guarded by docker_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

  1. User runs grm install <host> --user <user> --key <key> --name <name>
  2. CLI loads .env for GITEA_URL and GITEA_REGISTRATION_TOKEN
  3. RunnerManager.install() constructs extra-vars dict with registration token, runner name, Gitea URL, and optional admin token/labels
  4. Extra-vars are written to a temporary JSON file with 0600 permissions
  5. AnsibleExecutor.run() invokes ansible-playbook ansible/install-runner.yml with the temp file via --extra-vars @tempfile
  6. 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 .runner file and service state)
  7. Ansible output is streamed to a timestamped log file at ~/.local/state/grm/logs/ansible-<timestamp>.log
  8. On success, the runner is added to the local registry at ~/.local/share/grm/runners.json
  9. The temporary extra-vars file is deleted

Lifecycle command flow

  1. User runs grm <command> <runner_name> (e.g., grm stop prod-runner)
  2. RunnerManager._resolve_runner() looks up the runner in the local registry
  3. If --host and --user are provided, they override registry values
  4. The corresponding playbook is executed (e.g., stop-runner.yml)
  5. Ansible connects to the remote host and performs the action

List command flow

  1. User runs grm list
  2. RunnerManager.list_runners() reads all entries from the local registry
  3. For each runner, an Ansible ad-hoc command checks systemctl --user is-active gitea-runner
  4. 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/subuid and /etc/subgid (range: 100000-165535)
  • Rootless Docker socket at /run/user/<UID>/docker.sock
  • pasta for user-mode networking with IPv6 support (replaces slirp4netns, which lacks outgoing IPv6)
  • fuse-overlayfs for 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 driver
  • DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit — pasta's native port forwarding
  • DOCKERD_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-extras package (installed via the Docker APT repo).
  • Arch Linux — the docker package does not include these scripts, and no official Arch package provides them. The role fetches them from the upstream moby/moby contrib/ directory at a pinned, overridable git ref (gitea_runner_rootless_scripts_ref, default v28.5.1) and installs them into /usr/bin — co-located with docker/dockerd/rootlesskit, which is required because dockerd-rootless-setuptool.sh derives its BIN directory from its own location and expects those binaries alongside it. The rootlesskit package (a required rootless runtime dependency that is not pulled in by Arch's docker package) 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:

  1. Creates a temporary file via tempfile.mkstemp()
  2. Writes the extra-vars JSON to the file
  3. Sets permissions to 0600 (owner read/write only)
  4. Passes the file to Ansible via --extra-vars @tempfile
  5. Deletes the file in a finally block, 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.