Files
grm/docs/tech/architecture.md
T
emil 12f4aa4c92
Post-merge / configure-repo (push) Waiting to run
Post-merge / publish (push) Waiting to run
Post-merge / detect-type (push) Waiting to run
Post-merge / release (push) Waiting to run
Post-merge / sync-wiki (push) Waiting to run
Post-merge / validate-commit-msg (push) Waiting to run
Post-merge / vikunja (push) Waiting to run
Post-merge / badges (push) Waiting to run
GRM-141: feat: consolidate docs checks into devx-docs-check target
2026-07-07 22:15:25 +00:00

238 lines
13 KiB
Markdown

# 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 <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`):
```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), 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
```mermaid
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`
- `slirp4netns` for user-mode networking
- `fuse-overlayfs` for rootless container storage
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.
### 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.