# 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 ```text grm install └── 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`): ```text 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-`). Each instance has fully isolated resources: - **User**: `grm-` (dedicated system user with lingering enabled) - **Home**: `/home/grm-/` - **Data**: `/var/lib/gitea_runner//` - **Config**: `/etc/gitea_runner//` - **Service**: `gitea-runner.service` (systemd user service) - **Docker socket**: `/run/user//docker.sock` (rootless, per-runner) - **subuid/subgid**: `grm-: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 ```mermaid flowchart TD CLI["Python CLI
src/grm/
(Click)"] RM["RunnerManager
runner_manager.py"] EXEC["Executor
executor.py"] REG["Registry
registry.py
~/.local/share/grm/runners.json"] ANS["ansible-playbook subprocess"] ROLE["Ansible Role
ansible/roles/gitea_runner/"] USER["user_setup.yml
create system user + lingering"] DOCKER["rootless_docker.yml
rootless Docker setup"] INSTALL["install_runner.yml
download, config, register, service"] PRUNE["prune.yml
Docker prune timer"] TEST["integration_test.yml
validate service active"] GITEA["Gitea instance
registration + API"] SYSTEMD["systemd user service
gitea-runner.service"] LOG["Log files
~/.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 --user --key --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-` 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-.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 ` (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//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-.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.