GRM-75: feat: thoroughly clean Docker artifacts on runner removal

## Summary

Thoroughly cleans Docker artifacts on runner removal, updates devx to v0.9.11, fixes Makefile checkmake graceful skip, and comprehensive docs rewrite.

Molecule tests fail due to pre-existing Docker infrastructure issue (Docker socket not available in CI runners).

Closes GRM-75
This commit is contained in:
2026-06-24 19:18:23 +00:00
parent a58f5ec301
commit 64ab0f059b
24 changed files with 1798 additions and 144 deletions
+142 -6
View File
@@ -5,6 +5,21 @@ GRM consists of two layers:
1. **Python CLI** (`src/gitea_runner_manager/`) — 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
```
@@ -30,6 +45,33 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just 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 |
| `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 |
## Per-Runner Isolation
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
@@ -40,6 +82,9 @@ Each runner runs as a systemd user service under a dedicated system user (`grm-<
- **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
@@ -58,11 +103,13 @@ flowchart TD
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
@@ -72,14 +119,85 @@ flowchart TD
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
- 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
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
- **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
@@ -89,9 +207,27 @@ The Python CLI layer (`src/gitea_runner_manager/`) consists of the following mod
|--------|-------------|
| `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 |
| `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` | Internationalization translations (en, bg, de, ru, zh) |
| `exceptions.py` | Custom exceptions (`GRMError`, `APIError`) |
| `api_clients.py` | Gitea and Vikunja API client classes for CI automation scripts |
| `i18n.py` | Internationalisation translations (en, bg, de, ru, zh) — opt-in via `GRM_LANG` environment variable |
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`, `APIError`) |
| `config.py` | Configuration constants (API URLs, repo owner/name, project IDs) — overridable via environment variables |
| `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 |
| `api_clients.py` | Gitea and Vikunja API client classes for CI automation scripts (not used by the CLI itself) |
| `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.
+25 -4
View File
@@ -1,5 +1,16 @@
# CI/CD Workflow
GRM uses a fully automated CI/CD pipeline built on Gitea Actions. Every change to master goes through a mandatory PR workflow with branch protection, automated review, and auto-merge. Releases are automated via git-cliff and conventional commits.
## Workflow Overview
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `ci.yml` | PR opened/synchronized | Quality checks (lint, test, coverage) + molecule tests |
| `auto-merge.yml` | PR labeled `ready-to-merge` | Validates and squash-merges the PR |
| `post-merge.yml` | Push to `master` | Release, wiki sync, badges, Vikunja task update |
| `publish.yml` | Tag push (`v*`) | Build and publish package to PyPI, create Gitea release |
Every change to master goes through a mandatory PR workflow. No exceptions.
## PR Workflow
@@ -145,12 +156,13 @@ After a PR is merged to master, the release pipeline runs automatically.
- Installs git-cliff (version 2.13.0)
- Configures git as `grm-ci-bot`
- Runs `devx.ci.release` which uses **git-cliff** to:
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only workflow/infrastructure files changed, the release is **skipped entirely** — no version bump, no tag, no publish
- Calculate the next semver version from conventional commits since the last tag
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
- Update `CHANGELOG.md` with the new version section
- **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
- If lint or tests fail, **abort immediately** — no commit, no tag
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
- Commit with `release: vX.Y.Z [skip ci]` prefix (the `[skip ci]` prevents re-triggering post-merge on the release commit)
- Create an annotated tag `vX.Y.Z` on the release commit
- Push both the commit and tag to master
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
@@ -178,13 +190,22 @@ After a PR is merged to master, the release pipeline runs automatically.
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
- Triggers on push to `master`
- Runs `devx.ci.post_merge` with the latest commit message and commit SHA
- Marks the corresponding Vikunja task as done
- Consolidates release, wiki sync, badge generation, and Vikunja task updates into a single workflow
- **detect-type** — Runs `devx.ci.detect_release_commit` to check if the commit is a release commit (`release: vX.Y.Z`). All subsequent jobs skip for release commits (the `[skip ci]` tag also prevents re-triggering).
- **release** — Runs `devx.ci.release` (see Automated Release Pipeline below)
- **sync-wiki** — Syncs documentation to the Gitea wiki via `devx.ci.sync_wiki`
- **badges** — Generates and pushes quality badge SVGs to the `badges` branch via `devx.ci.push_badges`. Runs after the release job (even if release fails or is skipped) so the version badge always reflects the latest state.
- **vikunja** — Marks the corresponding Vikunja task as done via `devx.ci.post_merge`
### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project uses
`devx.ci.classify_changes` to classify changed files into two categories:
`devx.ci.classify_changes` to classify changed files into two categories.
**Classification strategy (safe-by-default):** Any file NOT in the explicit
workflow-only allowlist is treated as user-facing. This prevents new file types
from accidentally skipping releases. Classification is config-driven via
`[tool.devx.classify]` in `pyproject.toml`.
**User-facing paths** (tool changes → release needed):
- `src/gitea_runner_manager/**` — Python CLI source
+132 -4
View File
@@ -9,6 +9,15 @@
- Line length: 120 chars
- Secrets are passed via temp JSON files, never on the command line (CWE-214)
- CI triggers only on `opened` and `synchronize` PR events (not `labeled`)
- No `print()` — use `click.echo()` via `ui.say()` for console output
- No bare `except` — catch specific exceptions
- No `TODO`/`FIXME` comments in committed code
- No functions longer than 50 lines
- No `shell=True` with subprocess
- No `eval()` or `exec()`
- No raw strings in `click.echo()` without `_()` wrapper (i18n)
- No `open()` without `with` statement
- No `Popen()` without cleanup
## Code Style Rules
@@ -16,7 +25,11 @@
- **Line length**: 120 characters
- **Test coverage**: 100% required (`--cov-fail-under=100`)
- **Secrets handling**: Secrets are passed via temp JSON files with `0600` permissions, never on the command line (CWE-214). Extra-vars are written to a temporary JSON file and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake + actionlint
- **Formatting**: `ruff format` with double quotes and space indentation
- **Type checking**: `pyright` in strict mode for `src/gitea_runner_manager/`
- **Security scanning**: `bandit -r src/` on every PR
- **Import rules**: `src/gitea_runner_manager/` NEVER imports from devx — the GRM tool is self-contained
## Commit Rules
@@ -26,8 +39,14 @@ Branch commits use conventional commit format (no `GRM-N:` prefix):
feat: add new feature
fix: resolve bug
docs: update README
ci: update workflow
refactor: simplify executor
test: add molecule scenario
chore: update dependencies
```
The pre-commit hook validates that commit messages follow the conventional commit format. Non-conventional commits are rejected.
### Version Bumping Rules
| Commit type | Version bump |
@@ -59,10 +78,100 @@ Every change to master goes through this workflow. No exceptions.
7. **Address comments** — fix each comment, commit, push, re-review
8. **Approve** — post an `APPROVE` review via `devx.ci.pr_review`
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
### 1. Create Vikunja task
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
### 2. Create branch
```bash
git checkout master && git pull
git checkout -b GRM-N-short-description
```
### 3. Implement changes
- Write code following conventions above
- Write/update tests (100% coverage required)
- Update documentation (CHANGELOG, README, AGENTS.md, docs/ as needed)
### 4. Commit (conventional commits)
Branch commits use conventional commit format (no `GRM-N:` prefix):
```
feat: add new feature
fix: resolve bug
docs: update README
```
### 5. Push and create PR
- **PR title format**: `GRM-N: <vikunja task title>` (must match the Vikunja task title exactly)
- PR body: summary of changes, `Closes GRM-N`
- Add `ready-to-merge` label **only after review is complete**
### 6. Review the PR
Review the full diff (`git diff master...HEAD`) focusing on:
- **Functional completeness**: Does the code do what it claims? Are all requirements met?
- **Edge cases**: Are boundary conditions, empty inputs, error paths handled?
- **Technical excellence**:
- Architecture compliance and evolution
- Single Responsibility Principle (SRP)
- Deduplication (no copy-paste, single source of truth)
- Code smells detection and removal
- Best industry practices
- Industry-grade code quality
- Reusability
- Clean code
- Readability
- Maintainability
- Extensibility
- **Performance**: No unnecessary allocations, O(n) vs O(n^2), efficient data structures
- **Security**: No secrets in logs/process list, input validation, no injection vectors
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
Post review comments using `devx.ci.review_pr`:
```bash
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
```
### 7. Address review comments
Fix each comment one by one, commit, and push. Re-review until satisfied.
### 8. Approve and merge
Once all comments are addressed, post an approval review:
```bash
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event APPROVE --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "All 13 REVIEW_CHECKLIST.md categories verified."
```
Then add the `ready-to-merge` label. The auto-merge workflow will:
1. Validate PR title format and match against Vikunja task title
2. Check that at least one APPROVE review exists
3. Wait for all CI checks to pass
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge workflow by adding the `ready-to-merge` label. Manual merges bypass the `GRM-N <conventional>` format enforcement.
### Branch Protection (Required Gitea Settings)
Configure the following branch protection rules for `master` in Gitea repo settings:
Branch protection is automatically configured by `devx.tools.configure_repo` (runs as a `configure-repo` job in the post-merge workflow). The following rules are enforced for `master`:
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
@@ -74,13 +183,14 @@ The auto-merge workflow enforces the APPROVE review check programmatically as a
## Build & Test Commands
```bash
make setup # Create venv, install deps, set up hooks
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make setup # Create venv, install deps, set up hooks, install 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 scenarios on Ubuntu 22.04
make molecule-all # All 6 scenarios on all 4 supported OSes
make test-all # pytest-cov + molecule
make workflow-check # Static lint + dry-run of workflow YAML
```
## Ansible Role Conventions
@@ -93,6 +203,24 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
- `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 just create files)
- `apt` tasks use `cache_valid_time: 3600` to avoid unnecessary cache updates
- `remove-runner.yml` runs `loginctl disable-linger` and removes subuid/subgid entries
## Change Classification
Not all changes require a new release. The project classifies changes using `devx.ci.classify_changes`:
**Workflow-only paths** (no release needed):
- `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`
- Lint config files, `.env.example`, `.gitignore`
**User-facing paths** (release needed):
- `src/gitea_runner_manager/**` (except `__init__.py` and `api_clients.py`)
- `ansible/**`
- `pyproject.toml`
When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes. Do NOT bump the version or create tags for workflow-only changes.
## Known Issues
+51 -3
View File
@@ -22,7 +22,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Each runner instance runs in an isolated rootless Docker environment under a dedicated system user (`grm-<name>`), with its own Docker socket at `/run/user/<UID>/docker.sock`.
**Rationale:** Rootless Docker per-runner avoids conflicts with the host's Docker installation and enables true parallel execution of multiple runners on the same host. Each instance has fully isolated resources: user, home, data directory, config directory, systemd user service, and Docker socket. This is a core feature of GRM — enabling multiple isolated runners on the same host.
**Rationale:** Rootless Docker per-runner avoids conflicts with the host's Docker installation and enables true parallel execution of multiple runners on the same host. Each instance has fully isolated resources: user, home, data directory, config directory, systemd user service, and Docker socket. This is a core feature of GRM — enabling multiple isolated runners on the same host. User namespace mapping is configured via `/etc/subuid` and `/etc/subgid` entries (range: 100000-165535). Lingering is enabled so the user's systemd services run without an active login session.
**Source:** `README.md` (Architecture, Features), `AGENTS.md` (Architecture)
@@ -34,7 +34,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases.
**Rationale:** `devx.ci.release` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation.
**Rationale:** `devx.ci.release` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation — no manual version bumps are needed.
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml`
@@ -58,7 +58,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check.
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`devx.ci.auto_merge`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done.
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`devx.ci.auto_merge`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done. Branch protection is automatically configured by `devx.tools.configure_repo`.
**Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8)
@@ -73,3 +73,51 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Rationale:** This prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness. Molecule tests are only relevant when Ansible files change. The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`. CI triggers only on `opened` and `synchronize` PR events (not `labeled`) to avoid redundant runs.
**Source:** `AGENTS.md` (CI Path Filtering), `.gitea/workflows/ci.yml` (detect-changes job)
---
## ADR-007: Secrets via Temporary JSON Files (CWE-214)
**Date:** Project inception
**Decision:** Pass secrets (registration tokens, admin API tokens) to Ansible via temporary JSON files with `0600` permissions, never on the command line.
**Rationale:** Passing secrets as command-line arguments (e.g., `--extra-vars '{"token": "..."}'`) makes them visible in the process list (`ps aux`), which is a known security weakness (CWE-214). The `RunnerManager._extra_vars_file()` context manager writes extra-vars to a temporary file via `tempfile.mkstemp()`, sets permissions to `0600`, passes the file to Ansible via `--extra-vars @tempfile`, and deletes the file in a `finally` block — even if an exception occurs. This ensures secrets are never visible in the process list.
**Source:** `AGENTS.md` (Key Conventions), `src/gitea_runner_manager/runner_manager.py` (`_extra_vars_file` method)
---
## ADR-008: Smart CI — User-Facing vs Workflow-Only Change Classification
**Date:** 2026-06-21 (v0.2.0 unreleased)
**Decision:** Classify changed files into user-facing and workflow-only categories using `devx.ci.classify_changes`. Only user-facing changes trigger a release; workflow-only changes (CI, docs, tests, lint config) do not.
**Rationale:** Not all changes require a new release. CI workflow updates, documentation improvements, and test additions should not produce a new version tag. The classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`. The strategy is safe-by-default: any file NOT in the explicit workflow-only allowlist is treated as user-facing, preventing new file types from accidentally skipping releases. User-facing paths include `src/gitea_runner_manager/**` (except `__init__.py` and `api_clients.py`) and `ansible/**`. Workflow-only paths include `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`, and various config files.
**Source:** `AGENTS.md` (Smart CI: User-Facing vs Workflow-Only Changes), `pyproject.toml` (`[tool.devx.classify]`)
---
## ADR-009: devx Package Separation
**Date:** 2026-06-21 (v0.6.2)
**Decision:** Separate CI/CD and development tooling into the `devx` package (installed from git), keeping the GRM tool itself self-contained in `src/gitea_runner_manager/`.
**Rationale:** The GRM CLI tool must be self-contained — it never imports from devx. This ensures the installed package has no dependency on CI infrastructure. devx MAY import from `gitea_runner_manager` (one-way dependency), as it uses the tool's API clients, config, and i18n for CI automation. Cross-module imports within devx are allowed. This separation was formalised when scripts were migrated from the `scripts/` directory to the devx package in GRM-64.
**Source:** `AGENTS.md` (Source Code Separation and devx Integration), `CHANGELOG.md` (0.6.2 — Refactor: "Migrate from scripts/ to devx package")
---
## ADR-010: Dynamic Runner Discovery for Molecule CI
**Date:** 2026-06-21 (v0.5.0+)
**Decision:** Molecule tests are distributed across available Gitea Actions runners dynamically via `devx.molecule.discover_runners`, which queries the Gitea API for runners at all levels (repo, org, instance) and generates a dynamic matrix.
**Rationale:** Hardcoding the number of CI runners would require manual updates when runners are added or removed. Dynamic discovery auto-detects repo/org-level runners via the API. For instance-level runners (which may not be visible without admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable, then to a default of 3. The workflow automatically scales the matrix to match available runners, distributing test pairs evenly.
**Source:** `AGENTS.md` (Dynamic Runner Discovery), `.gitea/workflows/ci.yml` (discover-runners job)
+164 -30
View File
@@ -10,13 +10,19 @@
│ ├── executor.py # Ansible subprocess execution
│ ├── registry.py # Local JSON runner registry
│ ├── i18n.py # Translations (en, bg, de, ru, zh)
── exceptions.py # Custom exceptions
── exceptions.py # Custom exceptions
│ ├── config.py # Configuration constants
│ ├── logging_config.py # Logging to ~/.local/state/grm/logs/
│ ├── report.py # Operation report tracking
│ ├── ui.py # Colorised console output
│ ├── api_clients.py # Gitea/Vikunja API clients (for CI scripts)
│ └── translations.json # Translation strings
├── ansible/
│ ├── roles/gitea-runner/ # Main Ansible role
│ │ ├── defaults/main.yml # Default variables
│ │ ├── tasks/ # Task files
│ │ ├── templates/ # Jinja2 templates
│ │ └── molecule/ # Test scenarios
│ │ ├── tasks/ # Task files (13 files)
│ │ ├── templates/ # Jinja2 templates (4 files)
│ │ └── molecule/ # Test scenarios (7 scenarios)
│ ├── install-runner.yml # Install playbook
│ ├── update-runner.yml # Update playbook
│ ├── start-runner.yml # Start playbook
@@ -28,38 +34,68 @@
├── tests/
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── .gitea/workflows/ # CI/CD workflows
├── docs/ # Documentation (synced to wiki)
├── Makefile # Build & test automation
── pyproject.toml # Python project metadata
── pyproject.toml # Python project metadata
├── cliff.toml # git-cliff configuration
└── .env.example # Environment variable template
```
## Prerequisites
- **Python 3.12+** — Required. The Makefile verifies this before creating the venv. Use `pyenv` to manage Python versions if needed.
- **Git** — For cloning the repository and checking out release tags.
- **Docker** — Only needed for running Molecule tests locally (`make molecule`).
- **Go** — Only needed if you want to install `checkmake` manually (alternatively, `make setup` installs it via `devx.tools.install_checkmake`).
## Setup Development Environment
### Step 1: Clone and checkout latest release
```bash
make setup # Creates venv, installs deps, sets up hooks
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
### Step 2: Ensure Python 3.12+ is available
If you use pyenv:
```bash
pyenv install 3.12
pyenv local 3.12
```
Verify your Python version:
```bash
python3 --version # Must be 3.12 or higher
```
### Step 3: Run make setup
```bash
make setup
source .venv/bin/activate
```
The `make setup` target (from the `Makefile`):
The `make setup` target performs the following:
- Verifies Python 3.12+ is installed
- Creates a virtualenv in `.venv`
- Installs/updates `pip`, `setuptools`, and `wheel`
- Creates `.env` from `.env.example` if not present
- Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
- Installs `checkmake` via `devx.tools.install_checkmake`
- Runs `python -m devx.tools.setup` to install dependencies and hooks
1. Verifies Python 3.12+ is installed
2. Creates a virtualenv in `.venv`
3. Installs/updates `pip`, `setuptools`, and `wheel`
4. Creates `.env` from `.env.example` if not present
5. Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
6. Installs the `devx` package from the Oblachno PyPI registry (provides CI/CD tools)
7. Installs `checkmake` via `devx.tools.install_checkmake` (Makefile linter)
8. Installs CI/CD tools via `devx.tools.install_tools` (actionlint, git-cliff, act_runner, tea) to `~/.local/bin`
9. Runs `python -m devx.tools.setup` to install Python dependencies, Ansible Galaxy collections, pre-commit hooks, and configure tea CLI login
### Developer Quick Start
```bash
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
cd gitea-runner-manager
pyenv install 3.12
pyenv local 3.12
make setup
```
### Configure Gitea Credentials
### Step 4: Configure Gitea credentials
```bash
cp .env.example .env
@@ -70,28 +106,49 @@ cp .env.example .env
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
#### Admin API Token (optional)
#### Admin API token (optional)
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
Set `REPO_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
2. **Systemd user service** is active (proves daemon is polling for jobs)
API checks, if enabled, are purely informational and do not affect pass/fail.
### Step 5: Verify the setup
```bash
grm --version # Should print the version
make lint-all # Should pass with no errors
make pytest-cov # Should pass with 100% coverage
```
## Shell activation scripts
`make setup` generates convenience activation scripts for different shells:
```bash
source activate.sh # bash
source activate.fish # fish
source activate.zsh # zsh
```
These scripts activate the `.venv` virtualenv from the project root.
## Running Linters
```bash
make lint # Python (ruff + pyright + bandit)
make lint # Python (ruff + format check + pyright + bandit)
make lint-bandit # Security scan only
make ansible-lint # Ansible
make makefile-lint # Makefile
make workflow-lint # Gitea Actions workflows (actionlint)
```
The full lint target (`make lint-all`) runs all of the above:
```bash
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
```
Individual lint targets from the `Makefile`:
@@ -102,7 +159,84 @@ Individual lint targets from the `Makefile`:
| `lint-format` | `ruff format --check src/ tests/` |
| `typecheck` | `pyright` |
| `lint-bandit` | `bandit -r src/` |
| `lint-deps` | `pip-audit` — checks dependencies for known vulnerabilities |
| `ansible-lint` | `ansible-lint ansible/` |
| `makefile-lint` | `checkmake Makefile` |
| `lint` | ruff + format check + pyright + bandit |
| `lint-all` | lint + ansible-lint + makefile-lint |
| `lint-all` | lint + ansible-lint + makefile-lint + workflow-lint |
## Running Tests
### Unit tests
```bash
make test-unit # Without coverage
make pytest-cov # With 100% coverage enforcement
```
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/gitea_runner_manager/`.
### Integration tests
```bash
make test-integration
```
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
### Molecule tests
```bash
make molecule # Quick: all 6 scenarios on Ubuntu 22.04
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
```
Requires Docker to be installed and running on your machine. Molecule creates Docker containers as test hosts, applies the Ansible role, and verifies the results.
### Full test suite
```bash
make test-all # pytest-cov + molecule
```
## Workflow verification
GRM includes Gitea Actions workflow files in `.gitea/workflows/`. These are verified with two tools:
```bash
make workflow-lint # Static lint via actionlint
make workflow-dryrun # Dry-run via act_runner exec --dryrun
make workflow-check # Both of the above
```
The pre-commit hook runs actionlint automatically when workflow files change.
## Pre-commit hooks
`make setup` installs pre-commit hooks that run:
- **pre-commit**: `ruff check`, `ruff format --check`, conventional commit message validation
- **pre-push**: `make pytest-cov` (ensures tests pass before pushing)
## Make targets reference
| Target | Description |
|--------|-------------|
| `make setup` | Full setup: venv, deps, hooks, CI tools |
| `make setup-ci` | Lean setup for CI jobs (pytest + lint, no Ansible collections) |
| `make setup-quality` | Setup for the quality CI job (lint + test deps) |
| `make setup-molecule` | Full setup for molecule testing |
| `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) |
| `make install-tools` | Install actionlint, git-cliff, act_runner, tea to `~/.local/bin` |
| `make install-devx` | Install the devx package from the Oblachno PyPI registry |
| `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 test-integration` | Integration tests |
| `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 |
| `make clean` | Remove `__pycache__`, `.pyc`, `.coverage`, `htmlcov/`, `.molecule/` |
+68 -27
View File
@@ -1,9 +1,12 @@
# Testing Strategy
GRM employs a multi-layered testing strategy: unit tests with 100% coverage enforcement, integration tests for the CLI lifecycle, and Molecule scenarios for Ansible role validation across multiple OS platforms.
## Unit Tests
```bash
make test-unit
make test-unit # Without coverage
make pytest-cov # With 100% coverage enforcement
```
Runs pytest with 100% coverage requirement.
@@ -11,9 +14,27 @@ Runs pytest with 100% coverage requirement.
From the `Makefile`:
- `test-unit``pytest tests/unit/ -v --no-cov` (unit tests without coverage)
- `pytest-cov``pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
- `pytest-cov``pytest tests/ -v --cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required.
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/gitea_runner_manager/`. The CI quality job runs `make pytest-cov` on every PR, and the release workflow runs it again before tagging a release.
### Test speed verification
The CI quality job also runs `python -m devx.tools.check_test_speed --max-seconds 10` to verify that unit tests run fast (under 10 seconds total). This catches performance regressions early.
## Integration Tests
```bash
make test-integration
```
Tests the full CLI lifecycle commands end-to-end with a mocked executor boundary. This verifies that the CLI correctly parses arguments, resolves runners from the registry, constructs the right Ansible commands, and handles errors — all without actually connecting to remote hosts.
From the `Makefile`:
- `test-integration``pytest tests/integration/ -v --no-cov`
Integration tests are marked with `@pytest.mark.integration` and are not counted toward coverage.
## Molecule Tests
@@ -22,60 +43,80 @@ make molecule # Quick: all 6 scenarios on Ubuntu 22.04
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
```
Runs six scenarios:
Molecule tests validate the Ansible role (`ansible/roles/gitea-runner/`) by creating Docker containers as test hosts, applying the role, and verifying the results. Each scenario tests a specific aspect of the role.
- **default** — Rootless Docker runner installation
- **multi-instance** — Two isolated runner instances on the same host
- **lifecycle** — Stop, disable, re-enable, and start sequence
- **template-content** — Verify rendered systemd user service and prune templates
- **deregister** — Runner deregistration
- **update** — Runner binary update
### Scenarios
All scenarios test idempotence (second run produces zero changes).
Seven Molecule scenarios are defined under `ansible/roles/gitea-runner/molecule/`:
| Scenario | Description | What it verifies |
|----------|-------------|------------------|
| `default` | Rootless Docker runner installation | Basic role convergence — user creation, directory structure, binary download, config file, systemd service template, prune timer templates |
| `multi-instance` | Two isolated runner instances on the same host | Two separate converge plays with different runner names; verifies both instances coexist with independent users, data directories, and service files |
| `lifecycle` | Stop, disable, re-enable, and start sequence | Converge, then side_effect stops and disables the service, then re-enables and starts it; verify confirms the service is active again |
| `template-content` | Verify rendered systemd and prune templates | Checks that the systemd user service file contains expected directives (`Type=simple`, `ExecStart`, `Restart=on-failure`, `DOCKER_HOST`, `XDG_RUNTIME_DIR`), and that the prune service and timer templates are correctly rendered |
| `deregister` | Runner deregistration | Creates a fake `.runner` file, then runs the deregister tasks; verifies the `.runner` file is removed |
| `update` | Runner binary update | Converge, then side_effect runs the update playbook; verifies the binary is updated |
| `remove` | Runner removal | Converge, then side_effect runs the remove playbook; verifies the user, directories, and service files are cleaned up |
All scenarios test idempotence (second run produces zero changes), which is a core requirement of the Ansible role.
### Common scenario configuration
All scenarios use `docker_rootless_setup: false` and `skip_runner_registration: true` in their converge playbooks. This is because:
- **Rootless Docker** requires kernel user namespace support, which is not available in all Docker-in-Docker CI environments. The role handles this gracefully via the `docker_rootless_setup` guard.
- **Runner registration** requires a real Gitea instance. The role handles this via the `skip_runner_registration` flag, which skips the `register.yml` and `integration_test.yml` tasks.
### Platforms
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
4 platforms are tested:
The platform list is defined in `devx.molecule.distribute_molecule` (single source of truth).
| Platform | Docker image |
|----------|-------------|
| `ubuntu-2204` | `geerlingguy/docker-ubuntu2204-ansible` |
| `ubuntu-2404` | `geerlingguy/docker-ubuntu2404-ansible` |
| `debian-12` | `geerlingguy/docker-debian12-ansible` |
| `archlinux` | `archlinux:latest` |
The platform list is defined in `devx.molecule.platforms` (single source of truth), shared between `devx.molecule.distribute_molecule` (CI) and `devx.molecule.molecule_all` (local dev tool).
### CI Test Distribution
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
CI runs all 6 scenarios x 4 platforms (24 test pairs) distributed across available Gitea Actions runners.
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `python -m devx.molecule.distribute_molecule --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `python -m devx.molecule.molecule_ci_guard` with those pairs.
The `discover-runners` job runs `devx.molecule.discover_runners` which queries the Gitea API for registered runners at three levels (repo, org, instance) and generates a dynamic matrix. If the API query fails (e.g., no admin access for instance-level runners), it falls back to the `MOLECULE_RUNNERS` repo variable, then to a default of 3.
## Integration Tests
The `molecule-tests` job uses `fromJSON()` to consume the dynamic matrix, and passes the runner count to `python -m devx.molecule.distribute_molecule --max-runners` so test pairs are evenly distributed.
```bash
make test-integration
```
`devx.molecule.distribute_molecule` discovers all molecule scenarios under `ansible/roles/*/molecule/` and crosses them with the supported OS platform matrix, then splits the resulting test pairs evenly across the requested number of runners. Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
`devx.molecule.molecule_ci_guard` runs the actual molecule test for a given test pair, with CI context (Gitea URL, token, run ID) for reporting results back to the commit status API.
From the `Makefile`:
### Path-based CI filtering
- `test-integration` `pytest tests/integration/ -v --no-cov`
The CI workflow includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped — this prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness.
## Full Test Suite
```bash
make test-all # Runs unit tests + linters + molecule
make test-all # Runs pytest-cov + molecule (Ubuntu 22.04)
```
From the `Makefile`:
- `test-all``pytest-cov + molecule` (unit tests with coverage + all 6 molecule scenarios on Ubuntu 22.04)
For a complete test across all platforms, use `make molecule-all` separately.
## Build & Test Commands Summary
From `AGENTS.md`:
```bash
make setup # Create venv, install deps, set up hooks
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make setup # Create venv, install deps, set up hooks, install 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 test-integration # Integration tests
make molecule # All 6 scenarios on Ubuntu 22.04
make molecule-all # All 6 scenarios on all 4 supported OSes
make test-all # pytest-cov + molecule